mirror of
https://github.com/styppo/hamstr.git
synced 2024-10-18 05:23:28 +00:00
Hamstr v2 initial commit
This commit is contained in:
commit
61e78904d4
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
8
.eslintignore
Normal file
8
.eslintignore
Normal file
@ -0,0 +1,8 @@
|
||||
/dist
|
||||
/src-bex/www
|
||||
/src-capacitor
|
||||
/src-cordova
|
||||
/.quasar
|
||||
/node_modules
|
||||
.eslintrc.js
|
||||
babel.config.js
|
184
.eslintrc.js
Normal file
184
.eslintrc.js
Normal file
@ -0,0 +1,184 @@
|
||||
module.exports = {
|
||||
// https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy
|
||||
// This option interrupts the configuration hierarchy at this file
|
||||
// Remove this if you have an higher level ESLint config file (it usually happens into a monorepos)
|
||||
root: true,
|
||||
|
||||
parserOptions: {
|
||||
parser: '@babel/eslint-parser',
|
||||
ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features
|
||||
sourceType: 'module' // Allows for the use of imports
|
||||
},
|
||||
|
||||
env: {
|
||||
browser: true,
|
||||
'vue/setup-compiler-macros': true
|
||||
},
|
||||
|
||||
// Rules order is important, please avoid shuffling them
|
||||
extends: [
|
||||
// Base ESLint recommended rules
|
||||
'eslint:recommended',
|
||||
|
||||
// Uncomment any of the lines below to choose desired strictness,
|
||||
// but leave only one uncommented!
|
||||
// See https://eslint.vuejs.org/rules/#available-rules
|
||||
'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention)
|
||||
'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
|
||||
// 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
|
||||
|
||||
// https://github.com/prettier/eslint-config-prettier#installation
|
||||
// usage with Prettier, provided by 'eslint-config-prettier'.
|
||||
'prettier'
|
||||
],
|
||||
|
||||
plugins: [
|
||||
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files
|
||||
// required to lint *.vue files
|
||||
'vue',
|
||||
|
||||
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
|
||||
// Prettier has not been included as plugin to avoid performance impact
|
||||
// add it as an extension for your IDE
|
||||
|
||||
],
|
||||
|
||||
globals: {
|
||||
ga: 'readonly', // Google Analytics
|
||||
cordova: 'readonly',
|
||||
__statics: 'readonly',
|
||||
__QUASAR_SSR__: 'readonly',
|
||||
__QUASAR_SSR_SERVER__: 'readonly',
|
||||
__QUASAR_SSR_CLIENT__: 'readonly',
|
||||
__QUASAR_SSR_PWA__: 'readonly',
|
||||
process: 'readonly',
|
||||
Capacitor: 'readonly',
|
||||
chrome: 'readonly'
|
||||
},
|
||||
|
||||
// add your custom rules here
|
||||
rules: {
|
||||
'prefer-promise-reject-errors': 'off',
|
||||
|
||||
// allow debugger during development only
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
|
||||
// custom
|
||||
'vue/no-v-html': 0,
|
||||
'vue/multi-word-component-names': 0,
|
||||
|
||||
'accessor-pairs': 2,
|
||||
'arrow-spacing': [2, { before: true, after: true }],
|
||||
'block-spacing': [2, 'always'],
|
||||
'brace-style': [2, '1tbs', { allowSingleLine: true }],
|
||||
'comma-dangle': 0,
|
||||
'comma-spacing': [2, { before: false, after: true }],
|
||||
'comma-style': [2, 'last'],
|
||||
'constructor-super': 2,
|
||||
'curly': [0, 'multi-line'],
|
||||
'dot-location': [2, 'property'],
|
||||
'eol-last': 0,
|
||||
'eqeqeq': [2, 'allow-null'],
|
||||
'generator-star-spacing': [2, { before: true, after: true }],
|
||||
'handle-callback-err': [2, '^(err|error)$'],
|
||||
'indent': 0,
|
||||
'jsx-quotes': [2, 'prefer-double'],
|
||||
'key-spacing': [2, { beforeColon: false, afterColon: true }],
|
||||
'keyword-spacing': [2, { before: true, after: true }],
|
||||
'new-cap': 0,
|
||||
'new-parens': 0,
|
||||
'no-array-constructor': 2,
|
||||
'no-caller': 2,
|
||||
'no-class-assign': 2,
|
||||
'no-cond-assign': 2,
|
||||
'no-const-assign': 2,
|
||||
'no-control-regex': 0,
|
||||
'no-delete-var': 2,
|
||||
'no-dupe-args': 2,
|
||||
'no-dupe-class-members': 2,
|
||||
'no-dupe-keys': 2,
|
||||
'no-duplicate-case': 2,
|
||||
'no-empty-character-class': 2,
|
||||
'no-empty-pattern': 2,
|
||||
'no-eval': 0,
|
||||
'no-ex-assign': 2,
|
||||
'no-extend-native': 2,
|
||||
'no-extra-bind': 2,
|
||||
'no-extra-boolean-cast': 2,
|
||||
'no-extra-parens': [2, 'functions'],
|
||||
'no-fallthrough': 2,
|
||||
'no-floating-decimal': 2,
|
||||
'no-func-assign': 2,
|
||||
'no-implied-eval': 2,
|
||||
'no-inner-declarations': [0, 'functions'],
|
||||
'no-invalid-regexp': 2,
|
||||
'no-irregular-whitespace': 2,
|
||||
'no-iterator': 2,
|
||||
'no-label-var': 2,
|
||||
'no-labels': [2, { allowLoop: false, allowSwitch: false }],
|
||||
'no-lone-blocks': 2,
|
||||
'no-mixed-spaces-and-tabs': 2,
|
||||
'no-multi-spaces': 2,
|
||||
'no-multi-str': 2,
|
||||
'no-multiple-empty-lines': [2, { max: 2 }],
|
||||
'no-native-reassign': 2,
|
||||
'no-negated-in-lhs': 2,
|
||||
'no-new': 0,
|
||||
'no-new-func': 2,
|
||||
'no-new-object': 2,
|
||||
'no-new-require': 2,
|
||||
'no-new-symbol': 2,
|
||||
'no-new-wrappers': 2,
|
||||
'no-obj-calls': 2,
|
||||
'no-octal': 2,
|
||||
'no-octal-escape': 2,
|
||||
'no-path-concat': 0,
|
||||
'no-proto': 2,
|
||||
'no-redeclare': 2,
|
||||
'no-regex-spaces': 2,
|
||||
'no-return-assign': 0,
|
||||
'no-self-assign': 2,
|
||||
'no-self-compare': 2,
|
||||
'no-sequences': 2,
|
||||
'no-shadow-restricted-names': 2,
|
||||
'no-spaced-func': 2,
|
||||
'no-sparse-arrays': 2,
|
||||
'no-this-before-super': 2,
|
||||
'no-throw-literal': 2,
|
||||
'no-trailing-spaces': 2,
|
||||
'no-undef': 2,
|
||||
'no-undef-init': 2,
|
||||
'no-unexpected-multiline': 2,
|
||||
'no-unneeded-ternary': [2, { defaultAssignment: false }],
|
||||
'no-unreachable': 2,
|
||||
'no-unused-vars': [
|
||||
1,
|
||||
{ vars: 'local', args: 'none', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'no-useless-call': 2,
|
||||
'no-useless-constructor': 2,
|
||||
'no-with': 2,
|
||||
'one-var': [0, { initialized: 'never' }],
|
||||
'operator-linebreak': [
|
||||
0,
|
||||
'before',
|
||||
{ overrides: { '?': 'before', ':': 'before' } },
|
||||
],
|
||||
'padded-blocks': [2, 'never'],
|
||||
'quotes': [2, 'single', { avoidEscape: true, allowTemplateLiterals: true }],
|
||||
'semi': [2, 'never'],
|
||||
'semi-spacing': [2, { before: false, after: true }],
|
||||
'space-before-blocks': [2, 'always'],
|
||||
'space-before-function-paren': 0,
|
||||
'space-in-parens': [2, 'never'],
|
||||
'space-infix-ops': 2,
|
||||
'space-unary-ops': [2, { words: true, nonwords: false }],
|
||||
'spaced-comment': 0,
|
||||
'template-curly-spacing': [2, 'never'],
|
||||
'use-isnan': 2,
|
||||
'valid-typeof': 2,
|
||||
'wrap-iife': [2, 'any'],
|
||||
'yield-star-spacing': [2, 'both'],
|
||||
'yoda': [0],
|
||||
},
|
||||
}
|
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
.DS_Store
|
||||
.thumbs.db
|
||||
node_modules
|
||||
|
||||
# Quasar core related directories
|
||||
.quasar
|
||||
/dist
|
||||
|
||||
# Cordova related directories and files
|
||||
/src-cordova/node_modules
|
||||
/src-cordova/platforms
|
||||
/src-cordova/plugins
|
||||
/src-cordova/www
|
||||
|
||||
# Capacitor related directories and files
|
||||
/src-capacitor/www
|
||||
/src-capacitor/node_modules
|
||||
|
||||
# BEX related directories and files
|
||||
/src-bex/www
|
||||
/src-bex/js/core
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
3
.npmrc
Normal file
3
.npmrc
Normal file
@ -0,0 +1,3 @@
|
||||
# pnpm-related options
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
9
.postcssrc.js
Normal file
9
.postcssrc.js
Normal file
@ -0,0 +1,9 @@
|
||||
/* eslint-disable */
|
||||
// https://github.com/michael-ciniawsky/postcss-load-config
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
// to edit target browsers: use "browserslist" field in package.json
|
||||
require('autoprefixer')
|
||||
]
|
||||
}
|
15
.vscode/extensions.json
vendored
Normal file
15
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"editorconfig.editorconfig",
|
||||
"vue.volar",
|
||||
"wayou.vscode-todo-highlight"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"octref.vetur",
|
||||
"hookyqr.beautify",
|
||||
"dbaeumer.jshint",
|
||||
"ms-vscode.vscode-typescript-tslint-plugin"
|
||||
]
|
||||
}
|
15
.vscode/settings.json
vendored
Normal file
15
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"editor.guides.bracketPairs": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": [
|
||||
"source.fixAll.eslint"
|
||||
],
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"vue"
|
||||
]
|
||||
}
|
41
README.md
Normal file
41
README.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Hamstr (hamstr)
|
||||
|
||||
A twitter-style nostr web client
|
||||
|
||||
## Install the dependencies
|
||||
```bash
|
||||
yarn
|
||||
# or
|
||||
npm install
|
||||
```
|
||||
|
||||
### Start the app in development mode (hot-code reloading, error reporting, etc.)
|
||||
```bash
|
||||
quasar dev
|
||||
```
|
||||
|
||||
|
||||
### Lint the files
|
||||
```bash
|
||||
yarn lint
|
||||
# or
|
||||
npm run lint
|
||||
```
|
||||
|
||||
|
||||
### Format the files
|
||||
```bash
|
||||
yarn format
|
||||
# or
|
||||
npm run format
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Build the app for production
|
||||
```bash
|
||||
quasar build
|
||||
```
|
||||
|
||||
### Customize the configuration
|
||||
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js).
|
14
babel.config.js
Normal file
14
babel.config.js
Normal file
@ -0,0 +1,14 @@
|
||||
/* eslint-disable */
|
||||
|
||||
module.exports = api => {
|
||||
return {
|
||||
presets: [
|
||||
[
|
||||
'@quasar/babel-preset-app',
|
||||
api.caller(caller => caller && caller.target === 'node')
|
||||
? { targets: { node: 'current' } }
|
||||
: {}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
39
jsconfig.json
Normal file
39
jsconfig.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"src/*": [
|
||||
"src/*"
|
||||
],
|
||||
"app/*": [
|
||||
"*"
|
||||
],
|
||||
"components/*": [
|
||||
"src/components/*"
|
||||
],
|
||||
"layouts/*": [
|
||||
"src/layouts/*"
|
||||
],
|
||||
"pages/*": [
|
||||
"src/pages/*"
|
||||
],
|
||||
"assets/*": [
|
||||
"src/assets/*"
|
||||
],
|
||||
"boot/*": [
|
||||
"src/boot/*"
|
||||
],
|
||||
"stores/*": [
|
||||
"src/stores/*"
|
||||
],
|
||||
"vue$": [
|
||||
"node_modules/vue/dist/vue.runtime.esm-bundler.js"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
".quasar",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
52
package.json
Normal file
52
package.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "hamstr",
|
||||
"version": "0.0.1",
|
||||
"description": "A twitter-style nostr web client",
|
||||
"productName": "Hamstr",
|
||||
"author": "styppo",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js,.vue ./",
|
||||
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
|
||||
"test": "echo \"No test specified\" && exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^1.0.0",
|
||||
"bech32-buffer": "^0.2.1",
|
||||
"core-js": "^3.6.5",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"emoji-mart-vue-fast": "^12.0.1",
|
||||
"jdenticon": "^3.2.0",
|
||||
"moment": "^2.29.4",
|
||||
"pinia": "^2.0.11",
|
||||
"quasar": "^2.6.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-i18n": "^9.0.0",
|
||||
"vue-router": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.13.14",
|
||||
"@quasar/app-webpack": "^3.0.0",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"eslint-webpack-plugin": "^3.1.1",
|
||||
"prettier": "^2.5.1"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 10 Chrome versions",
|
||||
"last 10 Firefox versions",
|
||||
"last 4 Edge versions",
|
||||
"last 7 Safari versions",
|
||||
"last 8 Android versions",
|
||||
"last 8 ChromeAndroid versions",
|
||||
"last 8 FirefoxAndroid versions",
|
||||
"last 10 iOS versions",
|
||||
"last 5 Opera versions"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.22.1",
|
||||
"npm": ">= 6.13.4",
|
||||
"yarn": ">= 1.21.1"
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 63 KiB |
BIN
public/icons/favicon-128x128.png
Normal file
BIN
public/icons/favicon-128x128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
public/icons/favicon-16x16.png
Normal file
BIN
public/icons/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 859 B |
BIN
public/icons/favicon-32x32.png
Normal file
BIN
public/icons/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
BIN
public/icons/favicon-96x96.png
Normal file
BIN
public/icons/favicon-96x96.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
240
quasar.config.js
Normal file
240
quasar.config.js
Normal file
@ -0,0 +1,240 @@
|
||||
/* eslint-env node */
|
||||
|
||||
/*
|
||||
* This file runs in a Node context (it's NOT transpiled by Babel), so use only
|
||||
* the ES6 features that are supported by your Node version. https://node.green/
|
||||
*/
|
||||
|
||||
// Configuration for your app
|
||||
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js
|
||||
|
||||
|
||||
const ESLintPlugin = require('eslint-webpack-plugin')
|
||||
|
||||
|
||||
const { configure } = require('quasar/wrappers');
|
||||
|
||||
module.exports = configure(function (ctx) {
|
||||
return {
|
||||
// https://v2.quasar.dev/quasar-cli-webpack/supporting-ts
|
||||
supportTS: false,
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-webpack/prefetch-feature
|
||||
// preFetch: true,
|
||||
|
||||
// app boot file (/src/boot)
|
||||
// --> boot files are part of "main.js"
|
||||
// https://v2.quasar.dev/quasar-cli-webpack/boot-files
|
||||
boot: [
|
||||
'i18n',
|
||||
],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-css
|
||||
css: [
|
||||
'app.scss'
|
||||
],
|
||||
|
||||
// https://github.com/quasarframework/quasar/tree/dev/extras
|
||||
extras: [
|
||||
// 'ionicons-v4',
|
||||
// 'mdi-v5',
|
||||
// 'fontawesome-v6',
|
||||
// 'eva-icons',
|
||||
// 'themify',
|
||||
// 'line-awesome',
|
||||
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
|
||||
|
||||
'roboto-font', // optional, you are not bound to it
|
||||
'material-icons', // optional, you are not bound to it
|
||||
],
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-build
|
||||
build: {
|
||||
vueRouterMode: 'history', // available values: 'hash', 'history'
|
||||
|
||||
// transpile: false,
|
||||
// publicPath: '/',
|
||||
|
||||
// Add dependencies for transpiling with Babel (Array of string/regex)
|
||||
// (from node_modules, which are by default not transpiled).
|
||||
// Applies only if "transpile" is set to true.
|
||||
// transpileDependencies: [],
|
||||
|
||||
// rtl: true, // https://quasar.dev/options/rtl-support
|
||||
// preloadChunks: true,
|
||||
// showProgress: false,
|
||||
// gzip: true,
|
||||
// analyze: true,
|
||||
|
||||
// Options below are automatically set depending on the env, set them if you want to override
|
||||
// extractCSS: false,
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-webpack/handling-webpack
|
||||
// "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
|
||||
|
||||
chainWebpack (chain) {
|
||||
chain.plugin('eslint-webpack-plugin')
|
||||
.use(ESLintPlugin, [{ extensions: [ 'js', 'vue' ] }])
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-devServer
|
||||
devServer: {
|
||||
server: {
|
||||
type: 'http'
|
||||
},
|
||||
port: 8080,
|
||||
open: true // opens browser window automatically
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-webpack/quasar-config-js#Property%3A-framework
|
||||
framework: {
|
||||
config: {},
|
||||
|
||||
// iconSet: 'material-icons', // Quasar icon set
|
||||
// lang: 'en-US', // Quasar language pack
|
||||
|
||||
// For special cases outside of where the auto-import strategy can have an impact
|
||||
// (like functional components as one of the examples),
|
||||
// you can manually specify Quasar components/directives to be available everywhere:
|
||||
//
|
||||
// components: [],
|
||||
// directives: [],
|
||||
|
||||
// Quasar plugins
|
||||
plugins: []
|
||||
},
|
||||
|
||||
// animations: 'all', // --- includes all animations
|
||||
// https://quasar.dev/options/animations
|
||||
animations: [],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-webpack/developing-ssr/configuring-ssr
|
||||
ssr: {
|
||||
pwa: false,
|
||||
|
||||
// manualStoreHydration: true,
|
||||
// manualPostHydrationTrigger: true,
|
||||
|
||||
prodPort: 3000, // The default port that the production server should use
|
||||
// (gets superseded if process.env.PORT is specified at runtime)
|
||||
|
||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||
// Tell browser when a file from the server should expire from cache (in ms)
|
||||
|
||||
|
||||
chainWebpackWebserver (chain) {
|
||||
chain.plugin('eslint-webpack-plugin')
|
||||
.use(ESLintPlugin, [{ extensions: [ 'js' ] }])
|
||||
},
|
||||
|
||||
|
||||
middlewares: [
|
||||
ctx.prod ? 'compression' : '',
|
||||
'render' // keep this as last one
|
||||
]
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-webpack/developing-pwa/configuring-pwa
|
||||
pwa: {
|
||||
workboxPluginMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
|
||||
workboxOptions: {}, // only for GenerateSW
|
||||
|
||||
// for the custom service worker ONLY (/src-pwa/custom-service-worker.[js|ts])
|
||||
// if using workbox in InjectManifest mode
|
||||
|
||||
chainWebpackCustomSW (chain) {
|
||||
chain.plugin('eslint-webpack-plugin')
|
||||
.use(ESLintPlugin, [{ extensions: [ 'js' ] }])
|
||||
},
|
||||
|
||||
|
||||
manifest: {
|
||||
name: `Hamstr`,
|
||||
short_name: `Hamstr`,
|
||||
description: `A twitter-style nostr web client`,
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#027be3',
|
||||
icons: [
|
||||
{
|
||||
src: 'icons/icon-128x128.png',
|
||||
sizes: '128x128',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'icons/icon-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'icons/icon-256x256.png',
|
||||
sizes: '256x256',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'icons/icon-384x384.png',
|
||||
sizes: '384x384',
|
||||
type: 'image/png'
|
||||
},
|
||||
{
|
||||
src: 'icons/icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-webpack/developing-cordova-apps/configuring-cordova
|
||||
cordova: {
|
||||
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-webpack/developing-capacitor-apps/configuring-capacitor
|
||||
capacitor: {
|
||||
hideSplashscreen: true
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-webpack/developing-electron-apps/configuring-electron
|
||||
electron: {
|
||||
bundler: 'packager', // 'packager' or 'builder'
|
||||
|
||||
packager: {
|
||||
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
|
||||
|
||||
// OS X / Mac App Store
|
||||
// appBundleId: '',
|
||||
// appCategoryType: '',
|
||||
// osxSign: '',
|
||||
// protocol: 'myapp://path',
|
||||
|
||||
// Windows only
|
||||
// win32metadata: { ... }
|
||||
},
|
||||
|
||||
builder: {
|
||||
// https://www.electron.build/configuration/configuration
|
||||
|
||||
appId: 'hamstr'
|
||||
},
|
||||
|
||||
// "chain" is a webpack-chain object https://github.com/neutrinojs/webpack-chain
|
||||
|
||||
chainWebpackMain (chain) {
|
||||
chain.plugin('eslint-webpack-plugin')
|
||||
.use(ESLintPlugin, [{ extensions: [ 'js' ] }])
|
||||
},
|
||||
|
||||
|
||||
|
||||
chainWebpackPreload (chain) {
|
||||
chain.plugin('eslint-webpack-plugin')
|
||||
.use(ESLintPlugin, [{ extensions: [ 'js' ] }])
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
});
|
20
src/App.vue
Normal file
20
src/App.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<MainLayout />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {defineComponent} from 'vue'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
// import TestLayout from 'layouts/TestLayout.vue'
|
||||
import MainLayout from 'layouts/MainLayout.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
components: {
|
||||
MainLayout
|
||||
},
|
||||
setup() {
|
||||
useNostrStore().init()
|
||||
}
|
||||
})
|
||||
</script>
|
19
src/assets/theme/colors.scss
Normal file
19
src/assets/theme/colors.scss
Normal file
@ -0,0 +1,19 @@
|
||||
//$color-primary: #1DA1F2;
|
||||
$color-primary: #ee517d;
|
||||
//$color-primary: #F9A82C;
|
||||
$color-black: #14171a;
|
||||
$color-dark-gray: #657786;
|
||||
$color-light-gray: #aab8c2;
|
||||
$color-extra-light-gray: #e1e8ed;
|
||||
$color-bg: #15202b;
|
||||
$color-fg: #ffffff;
|
||||
$hr-color: #38444d;
|
||||
|
||||
$post-action-green: #17bf63;
|
||||
$post-action-blue: #1da1f2;
|
||||
$post-action-red: #e0245e;
|
||||
|
||||
$shadow-white: 0px 0px .5rem 0px rgba($color: $color-light-gray, $alpha: 0.3);
|
||||
|
||||
$border-dark: 1px solid rgb(56, 68, 77);
|
||||
$border-light: 1px solid rgba($color: $color-light-gray, $alpha: 0.5);
|
5
src/assets/variables.scss
Normal file
5
src/assets/variables.scss
Normal file
@ -0,0 +1,5 @@
|
||||
$phone-sm: 374px;
|
||||
$phone: 414px;
|
||||
$phone-lg: 755px;
|
||||
$tablet-sm: 1113px;
|
||||
$tablet: 1310px;
|
14
src/boot/i18n.js
Normal file
14
src/boot/i18n.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { boot } from 'quasar/wrappers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import messages from 'src/i18n'
|
||||
|
||||
export default boot(({ app }) => {
|
||||
const i18n = createI18n({
|
||||
locale: 'en-US',
|
||||
globalInjection: true,
|
||||
messages
|
||||
})
|
||||
|
||||
// Set i18n instance on app
|
||||
app.use(i18n)
|
||||
})
|
8
src/components/BaseIcon/icons/back.vue
Normal file
8
src/components/BaseIcon/icons/back.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
><path d="M16.67 0l2.83 2.829-9.339 9.175 9.339 9.167-2.83 2.829-12.17-11.996z" /></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/bookmarks.vue
Normal file
6
src/components/BaseIcon/icons/bookmarks.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-jwli3a r-4qtqp9 r-yyyyoo r-lwhw9o r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M19.9 23.5c-.157 0-.312-.05-.442-.144L12 17.928l-7.458 5.43c-.228.164-.53.19-.782.06-.25-.127-.41-.385-.41-.667V5.6c0-1.24 1.01-2.25 2.25-2.25h12.798c1.24 0 2.25 1.01 2.25 2.25v17.15c0 .282-.158.54-.41.668-.106.055-.223.082-.34.082zM12 16.25c.155 0 .31.048.44.144l6.71 4.883V5.6c0-.412-.337-.75-.75-.75H5.6c-.413 0-.75.338-.75.75v15.677l6.71-4.883c.13-.096.285-.144.44-.144z" /></g></svg>
|
||||
</template>
|
39
src/components/BaseIcon/icons/calendar.vue
Normal file
39
src/components/BaseIcon/icons/calendar.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
class="r-111h2gw r-4qtqp9 r-yyyyoo r-1xvli5t r-1d4mawv r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M19.708 2H4.292C3.028 2 2 3.028 2 4.292v15.416C2 20.972 3.028 22 4.292 22h15.416C20.972 22 22 20.972 22 19.708V4.292C22 3.028 20.972 2 19.708 2zm.792 17.708c0 .437-.355.792-.792.792H4.292c-.437 0-.792-.355-.792-.792V6.418c0-.437.354-.79.79-.792h15.42c.436 0 .79.355.79.79V19.71z" /><circle
|
||||
cx="7.032"
|
||||
cy="8.75"
|
||||
r="1.285"
|
||||
/><circle
|
||||
cx="7.032"
|
||||
cy="13.156"
|
||||
r="1.285"
|
||||
/><circle
|
||||
cx="16.968"
|
||||
cy="8.75"
|
||||
r="1.285"
|
||||
/><circle
|
||||
cx="16.968"
|
||||
cy="13.156"
|
||||
r="1.285"
|
||||
/><circle
|
||||
cx="12"
|
||||
cy="8.75"
|
||||
r="1.285"
|
||||
/><circle
|
||||
cx="12"
|
||||
cy="13.156"
|
||||
r="1.285"
|
||||
/><circle
|
||||
cx="7.032"
|
||||
cy="17.486"
|
||||
r="1.285"
|
||||
/><circle
|
||||
cx="12"
|
||||
cy="17.486"
|
||||
r="1.285"
|
||||
/></g></svg>
|
||||
</template>
|
8
src/components/BaseIcon/icons/close.vue
Normal file
8
src/components/BaseIcon/icons/close.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
><path d="M23 20.168l-8.185-8.187 8.185-8.174-2.832-2.807-8.182 8.179-8.176-8.179-2.81 2.81 8.186 8.196-8.186 8.184 2.81 2.81 8.203-8.192 8.18 8.192z" /></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/comment.vue
Normal file
6
src/components/BaseIcon/icons/comment.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1hdv0qi"
|
||||
><g><path d="M14.046 2.242l-4.148-.01h-.002c-4.374 0-7.8 3.427-7.8 7.802 0 4.098 3.186 7.206 7.465 7.37v3.828c0 .108.044.286.12.403.142.225.384.347.632.347.138 0 .277-.038.402-.118.264-.168 6.473-4.14 8.088-5.506 1.902-1.61 3.04-3.97 3.043-6.312v-.017c-.006-4.367-3.43-7.787-7.8-7.788zm3.787 12.972c-1.134.96-4.862 3.405-6.772 4.643V16.67c0-.414-.335-.75-.75-.75h-.396c-3.66 0-6.318-2.476-6.318-5.886 0-3.534 2.768-6.302 6.3-6.302l4.147.01h.002c3.532 0 6.3 2.766 6.302 6.296-.003 1.91-.942 3.844-2.514 5.176z" /></g></svg>
|
||||
</template>
|
19
src/components/BaseIcon/icons/editTweet.vue
Normal file
19
src/components/BaseIcon/icons/editTweet.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
class="r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1hdv0qi"
|
||||
><g><circle
|
||||
cx="5"
|
||||
cy="12"
|
||||
r="2"
|
||||
/><circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="2"
|
||||
/><circle
|
||||
cx="19"
|
||||
cy="12"
|
||||
r="2"
|
||||
/></g></svg>
|
||||
</template>
|
14
src/components/BaseIcon/icons/emoji.vue
Normal file
14
src/components/BaseIcon/icons/emoji.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-13gxpu9 r-4qtqp9 r-yyyyoo r-1q142lx r-50lct3 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1srniue"
|
||||
><g><path d="M12 22.75C6.072 22.75 1.25 17.928 1.25 12S6.072 1.25 12 1.25 22.75 6.072 22.75 12 17.928 22.75 12 22.75zm0-20C6.9 2.75 2.75 6.9 2.75 12S6.9 21.25 12 21.25s9.25-4.15 9.25-9.25S17.1 2.75 12 2.75z" /><path d="M12 17.115c-1.892 0-3.633-.95-4.656-2.544-.224-.348-.123-.81.226-1.035.348-.226.812-.124 1.036.226.747 1.162 2.016 1.855 3.395 1.855s2.648-.693 3.396-1.854c.224-.35.688-.45 1.036-.225.35.224.45.688.226 1.036-1.025 1.594-2.766 2.545-4.658 2.545z" /><circle
|
||||
cx="14.738"
|
||||
cy="9.458"
|
||||
r="1.478"
|
||||
/><circle
|
||||
cx="9.262"
|
||||
cy="9.458"
|
||||
r="1.478"
|
||||
/></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/explore.vue
Normal file
6
src/components/BaseIcon/icons/explore.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-jwli3a r-4qtqp9 r-yyyyoo r-lwhw9o r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M21 7.337h-3.93l.372-4.272c.036-.412-.27-.775-.682-.812-.417-.03-.776.27-.812.683l-.383 4.4h-6.32l.37-4.27c.037-.413-.27-.776-.68-.813-.42-.03-.777.27-.813.683l-.382 4.4H3.782c-.414 0-.75.337-.75.75s.336.75.75.75H7.61l-.55 6.327H3c-.414 0-.75.336-.75.75s.336.75.75.75h3.93l-.372 4.272c-.036.412.27.775.682.812l.066.003c.385 0 .712-.295.746-.686l.383-4.4h6.32l-.37 4.27c-.036.413.27.776.682.813l.066.003c.385 0 .712-.295.746-.686l.382-4.4h3.957c.413 0 .75-.337.75-.75s-.337-.75-.75-.75H16.39l.55-6.327H21c.414 0 .75-.336.75-.75s-.336-.75-.75-.75zm-6.115 7.826h-6.32l.55-6.326h6.32l-.55 6.326z" /></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/gif.vue
Normal file
6
src/components/BaseIcon/icons/gif.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-13gxpu9 r-4qtqp9 r-yyyyoo r-1q142lx r-50lct3 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1srniue"
|
||||
><g><path d="M19 10.5V8.8h-4.4v6.4h1.7v-2h2v-1.7h-2v-1H19zm-7.3-1.7h1.7v6.4h-1.7V8.8zm-3.6 1.6c.4 0 .9.2 1.2.5l1.2-1C9.9 9.2 9 8.8 8.1 8.8c-1.8 0-3.2 1.4-3.2 3.2s1.4 3.2 3.2 3.2c1 0 1.8-.4 2.4-1.1v-2.5H7.7v1.2h1.2v.6c-.2.1-.5.2-.8.2-.9 0-1.6-.7-1.6-1.6 0-.8.7-1.6 1.6-1.6z" /><path d="M20.5 2.02h-17c-1.24 0-2.25 1.007-2.25 2.247v15.507c0 1.238 1.01 2.246 2.25 2.246h17c1.24 0 2.25-1.008 2.25-2.246V4.267c0-1.24-1.01-2.247-2.25-2.247zm.75 17.754c0 .41-.336.746-.75.746h-17c-.414 0-.75-.336-.75-.746V4.267c0-.412.336-.747.75-.747h17c.414 0 .75.335.75.747v15.507z" /></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/graph.vue
Normal file
6
src/components/BaseIcon/icons/graph.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-13gxpu9 r-4qtqp9 r-yyyyoo r-1q142lx r-50lct3 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1srniue"
|
||||
><g><path d="M20.222 9.16h-1.334c.015-.09.028-.182.028-.277V6.57c0-.98-.797-1.777-1.778-1.777H3.5V3.358c0-.414-.336-.75-.75-.75s-.75.336-.75.75V20.83c0 .415.336.75.75.75s.75-.335.75-.75v-1.434h10.556c.98 0 1.778-.797 1.778-1.777v-2.313c0-.095-.014-.187-.028-.278h4.417c.98 0 1.778-.798 1.778-1.778v-2.31c0-.983-.797-1.78-1.778-1.78zM17.14 6.293c.152 0 .277.124.277.277v2.31c0 .154-.125.28-.278.28H3.5V6.29h13.64zm-2.807 9.014v2.312c0 .153-.125.277-.278.277H3.5v-2.868h10.556c.153 0 .277.126.277.28zM20.5 13.25c0 .153-.125.277-.278.277H3.5V10.66h16.722c.153 0 .278.124.278.277v2.313z" /></g></svg>
|
||||
</template>
|
20
src/components/BaseIcon/icons/hamburger.vue
Normal file
20
src/components/BaseIcon/icons/hamburger.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 75 40"
|
||||
>
|
||||
<rect
|
||||
width="75"
|
||||
height="6"
|
||||
/>
|
||||
<rect
|
||||
y="20"
|
||||
width="75"
|
||||
height="6"
|
||||
/>
|
||||
<rect
|
||||
y="40"
|
||||
width="75"
|
||||
height="6"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/help.vue
Normal file
6
src/components/BaseIcon/icons/help.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-111h2gw r-4qtqp9 r-yyyyoo r-1q142lx r-1xvli5t r-1b7u577 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M12.025 22.75c-5.928 0-10.75-4.822-10.75-10.75S6.098 1.25 12.025 1.25 22.775 6.072 22.775 12s-4.822 10.75-10.75 10.75zm0-20c-5.1 0-9.25 4.15-9.25 9.25s4.15 9.25 9.25 9.25 9.25-4.15 9.25-9.25-4.15-9.25-9.25-9.25z" /><path d="M13.064 17.47c0-.616-.498-1.114-1.114-1.114-.616 0-1.114.498-1.114 1.114 0 .615.498 1.114 1.114 1.114.616 0 1.114-.5 1.114-1.114zm3.081-7.528c0-2.312-1.882-4.194-4.194-4.194-2.312 0-4.194 1.882-4.194 4.194 0 .414.336.75.75.75s.75-.336.75-.75c0-1.485 1.21-2.694 2.695-2.694 1.486 0 2.695 1.21 2.695 2.694 0 1.486-1.21 2.695-2.694 2.695-.413 0-.75.336-.75.75v1.137c0 .414.337.75.75.75s.75-.336.75-.75v-.463c1.955-.354 3.445-2.06 3.445-4.118z" /></g></svg>
|
||||
</template>
|
7
src/components/BaseIcon/icons/home.vue
Normal file
7
src/components/BaseIcon/icons/home.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
><g>
|
||||
<path d="M22.58 7.35L12.475 1.897c-.297-.16-.654-.16-.95 0L1.425 7.35c-.486.264-.667.87-.405 1.356.18.335.525.525.88.525.16 0 .324-.038.475-.12l.734-.396 1.59 11.25c.216 1.214 1.31 2.062 2.66 2.062h9.282c1.35 0 2.444-.848 2.662-2.088l1.588-11.225.737.398c.485.263 1.092.082 1.354-.404.263-.486.08-1.093-.404-1.355zM12 15.435c-1.795 0-3.25-1.455-3.25-3.25s1.455-3.25 3.25-3.25 3.25 1.455 3.25 3.25-1.455 3.25-3.25 3.25z" /></g>
|
||||
</svg>
|
||||
</template>
|
10
src/components/BaseIcon/icons/image.vue
Normal file
10
src/components/BaseIcon/icons/image.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-13gxpu9 r-4qtqp9 r-yyyyoo r-1q142lx r-50lct3 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1srniue"
|
||||
><g><path d="M19.75 2H4.25C3.01 2 2 3.01 2 4.25v15.5C2 20.99 3.01 22 4.25 22h15.5c1.24 0 2.25-1.01 2.25-2.25V4.25C22 3.01 20.99 2 19.75 2zM4.25 3.5h15.5c.413 0 .75.337.75.75v9.676l-3.858-3.858c-.14-.14-.33-.22-.53-.22h-.003c-.2 0-.393.08-.532.224l-4.317 4.384-1.813-1.806c-.14-.14-.33-.22-.53-.22-.193-.03-.395.08-.535.227L3.5 17.642V4.25c0-.413.337-.75.75-.75zm-.744 16.28l5.418-5.534 6.282 6.254H4.25c-.402 0-.727-.322-.744-.72zm16.244.72h-2.42l-5.007-4.987 3.792-3.85 4.385 4.384v3.703c0 .413-.337.75-.75.75z" /><circle
|
||||
cx="8.868"
|
||||
cy="8.309"
|
||||
r="1.542"
|
||||
/></g></svg>
|
||||
</template>
|
8
src/components/BaseIcon/icons/left.vue
Normal file
8
src/components/BaseIcon/icons/left.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
><path d="M16.67 0l2.83 2.829-9.339 9.175 9.339 9.167-2.83 2.829-12.17-11.996z" /></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/like.vue
Normal file
6
src/components/BaseIcon/icons/like.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1hdv0qi"
|
||||
><g><path d="M12 21.638h-.014C9.403 21.59 1.95 14.856 1.95 8.478c0-3.064 2.525-5.754 5.403-5.754 2.29 0 3.83 1.58 4.646 2.73.814-1.148 2.354-2.73 4.645-2.73 2.88 0 5.404 2.69 5.404 5.755 0 6.376-7.454 13.11-10.037 13.157H12zM7.354 4.225c-2.08 0-3.903 1.988-3.903 4.255 0 5.74 7.034 11.596 8.55 11.658 1.518-.062 8.55-5.917 8.55-11.658 0-2.267-1.823-4.255-3.903-4.255-2.528 0-3.94 2.936-3.952 2.965-.23.562-1.156.562-1.387 0-.014-.03-1.425-2.965-3.954-2.965z" /></g></svg>
|
||||
</template>
|
7
src/components/BaseIcon/icons/link.vue
Normal file
7
src/components/BaseIcon/icons/link.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
class="r-111h2gw r-4qtqp9 r-yyyyoo r-1xvli5t r-1d4mawv r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M11.96 14.945c-.067 0-.136-.01-.203-.027-1.13-.318-2.097-.986-2.795-1.932-.832-1.125-1.176-2.508-.968-3.893s.942-2.605 2.068-3.438l3.53-2.608c2.322-1.716 5.61-1.224 7.33 1.1.83 1.127 1.175 2.51.967 3.895s-.943 2.605-2.07 3.438l-1.48 1.094c-.333.246-.804.175-1.05-.158-.246-.334-.176-.804.158-1.05l1.48-1.095c.803-.592 1.327-1.463 1.476-2.45.148-.988-.098-1.975-.69-2.778-1.225-1.656-3.572-2.01-5.23-.784l-3.53 2.608c-.802.593-1.326 1.464-1.475 2.45-.15.99.097 1.975.69 2.778.498.675 1.187 1.15 1.992 1.377.4.114.633.528.52.928-.092.33-.394.547-.722.547z" /><path d="M7.27 22.054c-1.61 0-3.197-.735-4.225-2.125-.832-1.127-1.176-2.51-.968-3.894s.943-2.605 2.07-3.438l1.478-1.094c.334-.245.805-.175 1.05.158s.177.804-.157 1.05l-1.48 1.095c-.803.593-1.326 1.464-1.475 2.45-.148.99.097 1.975.69 2.778 1.225 1.657 3.57 2.01 5.23.785l3.528-2.608c1.658-1.225 2.01-3.57.785-5.23-.498-.674-1.187-1.15-1.992-1.376-.4-.113-.633-.527-.52-.927.112-.4.528-.63.926-.522 1.13.318 2.096.986 2.794 1.932 1.717 2.324 1.224 5.612-1.1 7.33l-3.53 2.608c-.933.693-2.023 1.026-3.105 1.026z" /></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/lists.vue
Normal file
6
src/components/BaseIcon/icons/lists.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-jwli3a r-4qtqp9 r-yyyyoo r-lwhw9o r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M19.75 22H4.25C3.01 22 2 20.99 2 19.75V4.25C2 3.01 3.01 2 4.25 2h15.5C20.99 2 22 3.01 22 4.25v15.5c0 1.24-1.01 2.25-2.25 2.25zM4.25 3.5c-.414 0-.75.337-.75.75v15.5c0 .413.336.75.75.75h15.5c.414 0 .75-.337.75-.75V4.25c0-.413-.336-.75-.75-.75H4.25z" /><path d="M17 8.64H7c-.414 0-.75-.337-.75-.75s.336-.75.75-.75h10c.414 0 .75.335.75.75s-.336.75-.75.75zm0 4.11H7c-.414 0-.75-.336-.75-.75s.336-.75.75-.75h10c.414 0 .75.336.75.75s-.336.75-.75.75zm-5 4.11H7c-.414 0-.75-.335-.75-.75s.336-.75.75-.75h5c.414 0 .75.337.75.75s-.336.75-.75.75z" /></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/messages.vue
Normal file
6
src/components/BaseIcon/icons/messages.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-jwli3a r-4qtqp9 r-yyyyoo r-lwhw9o r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M19.25 3.018H4.75C3.233 3.018 2 4.252 2 5.77v12.495c0 1.518 1.233 2.753 2.75 2.753h14.5c1.517 0 2.75-1.235 2.75-2.753V5.77c0-1.518-1.233-2.752-2.75-2.752zm-14.5 1.5h14.5c.69 0 1.25.56 1.25 1.25v.714l-8.05 5.367c-.273.18-.626.182-.9-.002L3.5 6.482v-.714c0-.69.56-1.25 1.25-1.25zm14.5 14.998H4.75c-.69 0-1.25-.56-1.25-1.25V8.24l7.24 4.83c.383.256.822.384 1.26.384.44 0 .877-.128 1.26-.383l7.24-4.83v10.022c0 .69-.56 1.25-1.25 1.25z" /></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/moments.vue
Normal file
6
src/components/BaseIcon/icons/moments.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-111h2gw r-4qtqp9 r-yyyyoo r-1q142lx r-1xvli5t r-1b7u577 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M8.98 22.698c-.103 0-.205-.02-.302-.063-.31-.135-.49-.46-.44-.794l1.228-8.527H6.542c-.22 0-.43-.098-.573-.266-.144-.17-.204-.393-.167-.61L7.49 2.5c.062-.36.373-.625.74-.625h6.81c.23 0 .447.105.59.285.142.18.194.415.14.64l-1.446 6.075H19c.29 0 .553.166.678.428.124.262.087.57-.096.796L9.562 22.42c-.146.18-.362.276-.583.276zM7.43 11.812h2.903c.218 0 .425.095.567.26.142.164.206.382.175.598l-.966 6.7 7.313-8.995h-4.05c-.228 0-.445-.105-.588-.285-.142-.18-.194-.415-.14-.64l1.446-6.075H8.864L7.43 11.812z" /></g></svg>
|
||||
</template>
|
18
src/components/BaseIcon/icons/more.vue
Normal file
18
src/components/BaseIcon/icons/more.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-jwli3a r-4qtqp9 r-yyyyoo r-lwhw9o r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><circle
|
||||
cx="17"
|
||||
cy="12"
|
||||
r="1.5"
|
||||
/><circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="1.5"
|
||||
/><circle
|
||||
cx="7"
|
||||
cy="12"
|
||||
r="1.5"
|
||||
/><path d="M12 22.75C6.072 22.75 1.25 17.928 1.25 12S6.072 1.25 12 1.25 22.75 6.072 22.75 12 17.928 22.75 12 22.75zm0-20C6.9 2.75 2.75 6.9 2.75 12S6.9 21.25 12 21.25s9.25-4.15 9.25-9.25S17.1 2.75 12 2.75z" /></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/notifications.vue
Normal file
6
src/components/BaseIcon/icons/notifications.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-jwli3a r-4qtqp9 r-yyyyoo r-lwhw9o r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M21.697 16.468c-.02-.016-2.14-1.64-2.103-6.03.02-2.532-.812-4.782-2.347-6.335C15.872 2.71 14.01 1.94 12.005 1.93h-.013c-2.004.01-3.866.78-5.242 2.174-1.534 1.553-2.368 3.802-2.346 6.334.037 4.33-2.02 5.967-2.102 6.03-.26.193-.366.53-.265.838.102.308.39.515.712.515h4.92c.102 2.31 1.997 4.16 4.33 4.16s4.226-1.85 4.327-4.16h4.922c.322 0 .61-.206.71-.514.103-.307-.003-.645-.263-.838zM12 20.478c-1.505 0-2.73-1.177-2.828-2.658h5.656c-.1 1.48-1.323 2.66-2.828 2.66zM4.38 16.32c.74-1.132 1.548-3.028 1.524-5.896-.018-2.16.644-3.982 1.913-5.267C8.91 4.05 10.397 3.437 12 3.43c1.603.008 3.087.62 4.18 1.728 1.27 1.285 1.933 3.106 1.915 5.267-.024 2.868.785 4.765 1.525 5.896H4.38z" /></g></svg>
|
||||
</template>
|
8
src/components/BaseIcon/icons/pen.vue
Normal file
8
src/components/BaseIcon/icons/pen.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
><path d="M18.363 8.464l1.433 1.431-12.67 12.669-7.125 1.436 1.439-7.127 12.665-12.668 1.431 1.431-12.255 12.224-.726 3.584 3.584-.723 12.224-12.257zm-.056-8.464l-2.815 2.817 5.691 5.692 2.817-2.821-5.693-5.688zm-12.318 18.718l11.313-11.316-.705-.707-11.313 11.314.705.709z" /></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/profile.vue
Normal file
6
src/components/BaseIcon/icons/profile.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-jwli3a r-4qtqp9 r-yyyyoo r-lwhw9o r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M12 11.816c1.355 0 2.872-.15 3.84-1.256.814-.93 1.078-2.368.806-4.392-.38-2.825-2.117-4.512-4.646-4.512S7.734 3.343 7.354 6.17c-.272 2.022-.008 3.46.806 4.39.968 1.107 2.485 1.256 3.84 1.256zM8.84 6.368c.162-1.2.787-3.212 3.16-3.212s2.998 2.013 3.16 3.212c.207 1.55.057 2.627-.45 3.205-.455.52-1.266.743-2.71.743s-2.255-.223-2.71-.743c-.507-.578-.657-1.656-.45-3.205zm11.44 12.868c-.877-3.526-4.282-5.99-8.28-5.99s-7.403 2.464-8.28 5.99c-.172.692-.028 1.4.395 1.94.408.52 1.04.82 1.733.82h12.304c.693 0 1.325-.3 1.733-.82.424-.54.567-1.247.394-1.94zm-1.576 1.016c-.126.16-.316.246-.552.246H5.848c-.235 0-.426-.085-.552-.246-.137-.174-.18-.412-.12-.654.71-2.855 3.517-4.85 6.824-4.85s6.114 1.994 6.824 4.85c.06.242.017.48-.12.654z" /></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/repost.vue
Normal file
6
src/components/BaseIcon/icons/repost.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1hdv0qi"
|
||||
><g><path d="M23.77 15.67c-.292-.293-.767-.293-1.06 0l-2.22 2.22V7.65c0-2.068-1.683-3.75-3.75-3.75h-5.85c-.414 0-.75.336-.75.75s.336.75.75.75h5.85c1.24 0 2.25 1.01 2.25 2.25v10.24l-2.22-2.22c-.293-.293-.768-.293-1.06 0s-.294.768 0 1.06l3.5 3.5c.145.147.337.22.53.22s.383-.072.53-.22l3.5-3.5c.294-.292.294-.767 0-1.06zm-10.66 3.28H7.26c-1.24 0-2.25-1.01-2.25-2.25V6.46l2.22 2.22c.148.147.34.22.532.22s.384-.073.53-.22c.293-.293.293-.768 0-1.06l-3.5-3.5c-.293-.294-.768-.294-1.06 0l-3.5 3.5c-.294.292-.294.767 0 1.06s.767.293 1.06 0l2.22-2.22V16.7c0 2.068 1.683 3.75 3.75 3.75h5.85c.414 0 .75-.336.75-.75s-.337-.75-.75-.75z" /></g></svg>
|
||||
</template>
|
8
src/components/BaseIcon/icons/right.vue
Normal file
8
src/components/BaseIcon/icons/right.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
><path d="M7.33 24l-2.83-2.829 9.339-9.175-9.339-9.167 2.83-2.829 12.17 11.996z" /></svg>
|
||||
</template>
|
7
src/components/BaseIcon/icons/search.vue
Normal file
7
src/components/BaseIcon/icons/search.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
class="r-111h2gw r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-4wgw6l r-f727ji r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M21.53 20.47l-3.66-3.66C19.195 15.24 20 13.214 20 11c0-4.97-4.03-9-9-9s-9 4.03-9 9 4.03 9 9 9c2.215 0 4.24-.804 5.808-2.13l3.66 3.66c.147.146.34.22.53.22s.385-.073.53-.22c.295-.293.295-.767.002-1.06zM3.5 11c0-4.135 3.365-7.5 7.5-7.5s7.5 3.365 7.5 7.5-3.365 7.5-7.5 7.5-7.5-3.365-7.5-7.5z" /></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/settings.vue
Normal file
6
src/components/BaseIcon/icons/settings.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-111h2gw r-4qtqp9 r-yyyyoo r-1q142lx r-1xvli5t r-1b7u577 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M12 8.21c-2.09 0-3.79 1.7-3.79 3.79s1.7 3.79 3.79 3.79 3.79-1.7 3.79-3.79-1.7-3.79-3.79-3.79zm0 6.08c-1.262 0-2.29-1.026-2.29-2.29S10.74 9.71 12 9.71s2.29 1.026 2.29 2.29-1.028 2.29-2.29 2.29z" /><path d="M12.36 22.375h-.722c-1.183 0-2.154-.888-2.262-2.064l-.014-.147c-.025-.287-.207-.533-.472-.644-.286-.12-.582-.065-.798.115l-.116.097c-.868.725-2.253.663-3.06-.14l-.51-.51c-.836-.84-.896-2.154-.14-3.06l.098-.118c.186-.222.23-.523.122-.787-.11-.272-.358-.454-.646-.48l-.15-.014c-1.18-.107-2.067-1.08-2.067-2.262v-.722c0-1.183.888-2.154 2.064-2.262l.156-.014c.285-.025.53-.207.642-.473.11-.27.065-.573-.12-.795l-.094-.116c-.757-.908-.698-2.223.137-3.06l.512-.512c.804-.804 2.188-.865 3.06-.14l.116.098c.218.184.528.23.79.122.27-.112.452-.358.477-.643l.014-.153c.107-1.18 1.08-2.066 2.262-2.066h.722c1.183 0 2.154.888 2.262 2.064l.014.156c.025.285.206.53.472.64.277.117.58.062.794-.117l.12-.102c.867-.723 2.254-.662 3.06.14l.51.512c.836.838.896 2.153.14 3.06l-.1.118c-.188.22-.234.522-.123.788.112.27.36.45.646.478l.152.014c1.18.107 2.067 1.08 2.067 2.262v.723c0 1.183-.888 2.154-2.064 2.262l-.155.014c-.284.024-.53.205-.64.47-.113.272-.067.574.117.795l.1.12c.756.905.696 2.22-.14 3.06l-.51.51c-.807.804-2.19.864-3.06.14l-.115-.096c-.217-.183-.53-.23-.79-.122-.273.114-.455.36-.48.646l-.014.15c-.107 1.173-1.08 2.06-2.262 2.06zm-3.773-4.42c.3 0 .593.06.87.175.79.328 1.324 1.054 1.4 1.896l.014.147c.037.4.367.7.77.7h.722c.4 0 .73-.3.768-.7l.014-.148c.076-.842.61-1.567 1.392-1.892.793-.33 1.696-.182 2.333.35l.113.094c.178.148.366.18.493.18.206 0 .4-.08.546-.227l.51-.51c.284-.284.305-.73.048-1.038l-.1-.12c-.542-.65-.677-1.54-.352-2.323.326-.79 1.052-1.32 1.894-1.397l.155-.014c.397-.037.7-.367.7-.77v-.722c0-.4-.303-.73-.702-.768l-.152-.014c-.846-.078-1.57-.61-1.895-1.393-.326-.788-.19-1.678.353-2.327l.1-.118c.257-.31.236-.756-.048-1.04l-.51-.51c-.146-.147-.34-.227-.546-.227-.127 0-.315.032-.492.18l-.12.1c-.634.528-1.55.67-2.322.354-.788-.327-1.32-1.052-1.397-1.896l-.014-.155c-.035-.397-.365-.7-.767-.7h-.723c-.4 0-.73.303-.768.702l-.014.152c-.076.843-.608 1.568-1.39 1.893-.787.326-1.693.183-2.33-.35l-.118-.096c-.18-.15-.368-.18-.495-.18-.206 0-.4.08-.546.226l-.512.51c-.282.284-.303.73-.046 1.038l.1.118c.54.653.677 1.544.352 2.325-.327.788-1.052 1.32-1.895 1.397l-.156.014c-.397.037-.7.367-.7.77v.722c0 .4.303.73.702.768l.15.014c.848.078 1.573.612 1.897 1.396.325.786.19 1.675-.353 2.325l-.096.115c-.26.31-.238.756.046 1.04l.51.51c.146.147.34.227.546.227.127 0 .315-.03.492-.18l.116-.096c.406-.336.923-.524 1.453-.524z" /></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/share.vue
Normal file
6
src/components/BaseIcon/icons/share.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-4qtqp9 r-yyyyoo r-1xvli5t r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1hdv0qi"
|
||||
><g><path d="M17.53 7.47l-5-5c-.293-.293-.768-.293-1.06 0l-5 5c-.294.293-.294.768 0 1.06s.767.294 1.06 0l3.72-3.72V15c0 .414.336.75.75.75s.75-.336.75-.75V4.81l3.72 3.72c.146.147.338.22.53.22s.384-.072.53-.22c.293-.293.293-.767 0-1.06z" /><path d="M19.708 21.944H4.292C3.028 21.944 2 20.916 2 19.652V14c0-.414.336-.75.75-.75s.75.336.75.75v5.652c0 .437.355.792.792.792h15.416c.437 0 .792-.355.792-.792V14c0-.414.336-.75.75-.75s.75.336.75.75v5.652c0 1.264-1.028 2.292-2.292 2.292z" /></g></svg>
|
||||
</template>
|
14
src/components/BaseIcon/icons/smile.vue
Normal file
14
src/components/BaseIcon/icons/smile.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-13gxpu9 r-4qtqp9 r-yyyyoo r-1q142lx r-50lct3 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1srniue"
|
||||
><g><path d="M12 22.75C6.072 22.75 1.25 17.928 1.25 12S6.072 1.25 12 1.25 22.75 6.072 22.75 12 17.928 22.75 12 22.75zm0-20C6.9 2.75 2.75 6.9 2.75 12S6.9 21.25 12 21.25s9.25-4.15 9.25-9.25S17.1 2.75 12 2.75z" /><path d="M12 17.115c-1.892 0-3.633-.95-4.656-2.544-.224-.348-.123-.81.226-1.035.348-.226.812-.124 1.036.226.747 1.162 2.016 1.855 3.395 1.855s2.648-.693 3.396-1.854c.224-.35.688-.45 1.036-.225.35.224.45.688.226 1.036-1.025 1.594-2.766 2.545-4.658 2.545z" /><circle
|
||||
cx="14.738"
|
||||
cy="9.458"
|
||||
r="1.478"
|
||||
/><circle
|
||||
cx="9.262"
|
||||
cy="9.458"
|
||||
r="1.478"
|
||||
/></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/tick.vue
Normal file
6
src/components/BaseIcon/icons/tick.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-13gxpu9 r-4qtqp9 r-yyyyoo r-1q142lx r-1xvli5t r-19u6a5r r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M9 20c-.264 0-.52-.104-.707-.293l-4.785-4.785c-.39-.39-.39-1.023 0-1.414s1.023-.39 1.414 0l3.946 3.945L18.075 4.41c.32-.45.94-.558 1.395-.24.45.318.56.942.24 1.394L9.817 19.577c-.17.24-.438.395-.732.42-.028.002-.057.003-.085.003z" /></g></svg>
|
||||
</template>
|
6
src/components/BaseIcon/icons/topics.vue
Normal file
6
src/components/BaseIcon/icons/topics.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-111h2gw r-4qtqp9 r-yyyyoo r-1q142lx r-1xvli5t r-1b7u577 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M12.003 23.274c-.083 0-.167-.014-.248-.042-.3-.105-.502-.39-.502-.708v-4.14c-2.08-.172-4.013-1.066-5.506-2.56-3.45-3.45-3.45-9.062 0-12.51s9.062-3.45 12.512 0c3.096 3.097 3.45 8.07.82 11.565l-6.49 8.112c-.146.182-.363.282-.587.282zm0-21.05c-1.882 0-3.763.717-5.195 2.15-2.864 2.863-2.864 7.524 0 10.39 1.388 1.387 3.233 2.15 5.195 2.15.414 0 .75.337.75.75v2.72l5.142-6.425c2.17-2.885 1.876-7.014-.696-9.587-1.434-1.43-3.316-2.148-5.197-2.148z" /><path d="M15.55 8.7h-7.1c-.413 0-.75-.337-.75-.75s.337-.75.75-.75h7.1c.413 0 .75.335.75.75s-.337.75-.75.75zm-3.05 3.238H8.45c-.413 0-.75-.336-.75-.75s.337-.75.75-.75h4.05c.414 0 .75.336.75.75s-.336.75-.75.75z" /></g></svg>
|
||||
</template>
|
7
src/components/BaseIcon/icons/trash.vue
Normal file
7
src/components/BaseIcon/icons/trash.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
class="r-daml9f r-4qtqp9 r-yyyyoo r-1q142lx r-1xvli5t r-1b7u577 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
|
||||
><g><path d="M20.746 5.236h-3.75V4.25c0-1.24-1.01-2.25-2.25-2.25h-5.5c-1.24 0-2.25 1.01-2.25 2.25v.986h-3.75c-.414 0-.75.336-.75.75s.336.75.75.75h.368l1.583 13.262c.216 1.193 1.31 2.027 2.658 2.027h8.282c1.35 0 2.442-.834 2.664-2.072l1.577-13.217h.368c.414 0 .75-.336.75-.75s-.335-.75-.75-.75zM8.496 4.25c0-.413.337-.75.75-.75h5.5c.413 0 .75.337.75.75v.986h-7V4.25zm8.822 15.48c-.1.55-.664.795-1.18.795H7.854c-.517 0-1.083-.246-1.175-.75L5.126 6.735h13.74L17.32 19.732z" /><path d="M10 17.75c.414 0 .75-.336.75-.75v-7c0-.414-.336-.75-.75-.75s-.75.336-.75.75v7c0 .414.336.75.75.75zm4 0c.414 0 .75-.336.75-.75v-7c0-.414-.336-.75-.75-.75s-.75.336-.75.75v7c0 .414.336.75.75.75z" /></g></svg>
|
||||
</template>
|
8
src/components/BaseIcon/icons/twitter.vue
Normal file
8
src/components/BaseIcon/icons/twitter.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="r-jwli3a r-4qtqp9 r-yyyyoo r-16y2uox r-1q142lx r-8kz0gk r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-1srniue"
|
||||
><g><path
|
||||
d="M23.643 4.937c-.835.37-1.732.62-2.675.733.962-.576 1.7-1.49 2.048-2.578-.9.534-1.897.922-2.958 1.13-.85-.904-2.06-1.47-3.4-1.47-2.572 0-4.658 2.086-4.658 4.66 0 .364.042.718.12 1.06-3.873-.195-7.304-2.05-9.602-4.868-.4.69-.63 1.49-.63 2.342 0 1.616.823 3.043 2.072 3.878-.764-.025-1.482-.234-2.11-.583v.06c0 2.257 1.605 4.14 3.737 4.568-.392.106-.803.162-1.227.162-.3 0-.593-.028-.877-.082.593 1.85 2.313 3.198 4.352 3.234-1.595 1.25-3.604 1.995-5.786 1.995-.376 0-.747-.022-1.112-.065 2.062 1.323 4.51 2.093 7.14 2.093 8.57 0 13.255-7.098 13.255-13.254 0-.2-.005-.402-.014-.602.91-.658 1.7-1.477 2.323-2.41z"
|
||||
/></g></svg>
|
||||
</template>
|
22
src/components/BaseIcon/index.vue
Normal file
22
src/components/BaseIcon/index.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<component :is="iconComponent" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {defineComponent, defineAsyncComponent} from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseIcon',
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'home'
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
iconComponent() {
|
||||
return defineAsyncComponent(() => import(`./icons/${this.icon}.vue`))
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
58
src/components/Bech32Label.vue
Normal file
58
src/components/Bech32Label.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<span class="bech32">
|
||||
<span class="bech32-prefix">{{ computedPrefix }}</span>
|
||||
<span class="bech32-value">{{ value }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {hexToBech32} from 'src/utils/utils'
|
||||
|
||||
export default {
|
||||
name: 'Bech32Label',
|
||||
props: {
|
||||
bech32: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
hex: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
computedPrefix() {
|
||||
return this.bech32
|
||||
? this.bech32.substring(0, 4)
|
||||
: this.prefix
|
||||
},
|
||||
value() {
|
||||
const value = this.bech32
|
||||
? this.bech32.substring(4)
|
||||
: hexToBech32(this.hex, '')
|
||||
return this.shorten(value)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
shorten(str) {
|
||||
return str.substr(0, 5) + '…' + str.substr(-5)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bech32 {
|
||||
&-prefix {
|
||||
font-size: 0.7em;
|
||||
opacity: .9;
|
||||
}
|
||||
&-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
43
src/components/ButtonLoadMore.vue
Normal file
43
src/components/ButtonLoadMore.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="load-more">
|
||||
<q-btn
|
||||
:loading="loading"
|
||||
:label="reachedEnd ? 'reached end' : label"
|
||||
:disable="reachedEnd"
|
||||
@click="$emit('click')"
|
||||
size="md"
|
||||
flat
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BaseButtonLoadMore',
|
||||
emits: ['click'],
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
reachedEnd: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'load more'
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.load-more button {
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
</style>
|
93
src/components/CreatePost/AutoSizeTextarea.vue
Normal file
93
src/components/CreatePost/AutoSizeTextarea.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<textarea
|
||||
v-model="text"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="resize"
|
||||
@focus="resize"
|
||||
ref="textarea"
|
||||
></textarea>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'AutoSizeTextarea',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
minHeight: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
maxHeight: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'What\'s happening?',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
text: this.modelValue,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(value) {
|
||||
this.text = value
|
||||
},
|
||||
text(value) {
|
||||
this.$nextTick(this.resize)
|
||||
this.$emit('update:modelValue', value)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
resize() {
|
||||
this.$nextTick(() => {
|
||||
const textarea = this.$refs.textarea
|
||||
textarea.style.height = 'inherit'
|
||||
let height = textarea.scrollHeight
|
||||
|
||||
if (this.minHeight) {
|
||||
height = Math.max(height, this.minHeight)
|
||||
}
|
||||
if (this.maxHeight) {
|
||||
height = Math.min(height, this.maxHeight)
|
||||
}
|
||||
textarea.style.height = height + 'px'
|
||||
})
|
||||
},
|
||||
insertText(text) {
|
||||
const textarea = this.$refs.textarea
|
||||
textarea.setRangeText(text, textarea.selectionStart, textarea.selectionEnd)
|
||||
|
||||
textarea.focus()
|
||||
if (textarea.selectionStart === textarea.selectionEnd) {
|
||||
const caretPos = textarea.selectionStart + text.length
|
||||
textarea.setSelectionRange(caretPos, caretPos)
|
||||
}
|
||||
this.$emit('update:modelValue', textarea.value)
|
||||
},
|
||||
focus() {
|
||||
this.$refs.textarea.focus()
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.text) {
|
||||
this.resize()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
115
src/components/CreatePost/CreatePostDialog.vue
Normal file
115
src/components/CreatePost/CreatePostDialog.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<q-dialog
|
||||
v-model="app.createPostDialog.open"
|
||||
@before-show="updateAncestor"
|
||||
@show="$refs.postEditor.focus()"
|
||||
>
|
||||
<div class="create-post-dialog">
|
||||
<q-btn v-close-popup icon="close" size="md" flat round class="icon" />
|
||||
|
||||
<ListPost
|
||||
v-if="ancestor"
|
||||
:note="ancestor"
|
||||
connector-bottom
|
||||
/>
|
||||
<PostEditor
|
||||
ref="postEditor"
|
||||
class="post-editor"
|
||||
:class="{standalone: !isReply}"
|
||||
:placeholder="placeholderText"
|
||||
:connector="isReply"
|
||||
:ancestor="ancestor"
|
||||
@publish="app.createPostDialog.open = false"
|
||||
compact
|
||||
expanded
|
||||
/>
|
||||
</div>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListPost from 'components/Post/ListPost.vue'
|
||||
import PostEditor from 'components/CreatePost/PostEditor.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
|
||||
export default {
|
||||
name: 'CreatePostDialog',
|
||||
components: {
|
||||
ListPost,
|
||||
PostEditor
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ancestor: null,
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
app: useAppStore(),
|
||||
nostr: useNostrStore(),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
paramAncestor() {
|
||||
return this.app.createPostDialog?.params?.ancestor
|
||||
},
|
||||
isReply() {
|
||||
return !!this.paramAncestor
|
||||
},
|
||||
placeholderText() {
|
||||
return this.isReply
|
||||
? 'Post your reply'
|
||||
: 'What\'s happening?'
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateAncestor() {
|
||||
this.ancestor = this.paramAncestor
|
||||
? this.nostr.getNote(this.paramAncestor)
|
||||
: null
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.updateAncestor()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
@import "assets/variables.scss";
|
||||
|
||||
.create-post-dialog {
|
||||
position: relative;
|
||||
background-color: $color-bg;
|
||||
padding: 3rem 1rem 1rem;
|
||||
min-width: 660px;
|
||||
.icon {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: .5rem;
|
||||
left: .5rem;
|
||||
fill: #fff;
|
||||
}
|
||||
.post {
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.post-editor {
|
||||
padding: 0;
|
||||
&.standalone {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $phone-lg) {
|
||||
.create-post-dialog {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
47
src/components/CreatePost/EmojiPicker.vue
Normal file
47
src/components/CreatePost/EmojiPicker.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<Picker
|
||||
:data="emojiIndex"
|
||||
:native="true"
|
||||
:show-skin-tones="true"
|
||||
emoji="thumbsup"
|
||||
title=""
|
||||
color="#ee517d"
|
||||
@select="onSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import emojiData from 'emoji-mart-vue-fast/data/all.json'
|
||||
import { Picker, EmojiIndex } from 'emoji-mart-vue-fast/src'
|
||||
import 'emoji-mart-vue-fast/css/emoji-mart.css'
|
||||
|
||||
export default {
|
||||
name: 'EmojiPicker',
|
||||
components: {
|
||||
Picker
|
||||
},
|
||||
emits: ['select'],
|
||||
data() {
|
||||
return {
|
||||
emojiIndex: new EmojiIndex(emojiData)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSelect(emoji) {
|
||||
this.$emit('select', emoji)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.emoji-mart-preview {
|
||||
overflow: hidden;
|
||||
&-name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
&-emoticons {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
290
src/components/CreatePost/PostEditor.vue
Normal file
290
src/components/CreatePost/PostEditor.vue
Normal file
@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div
|
||||
class="post-editor"
|
||||
:class="{compact, collapsed, connector}"
|
||||
>
|
||||
<div class="post-editor-author">
|
||||
<div v-if="connector" class="connector-top">
|
||||
<div class="connector-line"></div>
|
||||
</div>
|
||||
<UserAvatar :pubkey="app.myPubkey" />
|
||||
</div>
|
||||
<div class="post-editor-content">
|
||||
<div class="input-section">
|
||||
<AutoSizeTextarea
|
||||
v-model="content"
|
||||
ref="textarea"
|
||||
:placeholder="placeholder"
|
||||
:disabled="publishing"
|
||||
@focus.once="collapsed = false"
|
||||
/>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="controls-media">
|
||||
<div class="controls-media-item">
|
||||
<BaseIcon icon="emoji" />
|
||||
<q-menu ref="menuEmojiPicker">
|
||||
<EmojiPicker @select="onEmojiSelected"/>
|
||||
</q-menu>
|
||||
</div>
|
||||
<div class="controls-media-item disabled">
|
||||
<BaseIcon icon="image" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-submit">
|
||||
<button :disabled="!hasContent() || publishing" @click="publishPost" class="btn">
|
||||
<q-spinner v-if="publishing" />
|
||||
<span v-else>Post</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-editor-fake-submit" v-if="collapsed">
|
||||
<button class="btn" disabled>Post</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AutoSizeTextarea from 'components/CreatePost/AutoSizeTextarea.vue'
|
||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import BaseIcon from 'components/BaseIcon/index.vue'
|
||||
import EmojiPicker from 'components/CreatePost/EmojiPicker.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
|
||||
export default {
|
||||
name: 'PostEditor',
|
||||
components: {
|
||||
AutoSizeTextarea,
|
||||
BaseIcon,
|
||||
UserAvatar,
|
||||
EmojiPicker,
|
||||
},
|
||||
props: {
|
||||
ancestor: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'What\'s happening?',
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
connector: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
emits: ['publish'],
|
||||
data() {
|
||||
return {
|
||||
content: '',
|
||||
collapsed: this.compact && !this.expanded,
|
||||
publishing: false,
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
app: useAppStore(),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasContent() {
|
||||
return this.content.trim().length > 0
|
||||
},
|
||||
onEmojiSelected(emoji) {
|
||||
this.$refs.menuEmojiPicker.hide()
|
||||
this.$refs.textarea.insertText(emoji.native)
|
||||
},
|
||||
focus() {
|
||||
this.$refs.textarea.focus()
|
||||
},
|
||||
reset() {
|
||||
this.content = ''
|
||||
},
|
||||
async publishPost() {
|
||||
this.publishing = true
|
||||
const post = {
|
||||
message: this.content,
|
||||
tags: this.buildTags(),
|
||||
}
|
||||
try {
|
||||
// FIXME
|
||||
const event = await this.$store.dispatch('sendPost', post)
|
||||
|
||||
this.reset()
|
||||
this.$emit('publish', event)
|
||||
|
||||
// TODO i18n
|
||||
const postType = this.replyTo.length ? 'Reply' : 'Post'
|
||||
this.$q.notify({
|
||||
message: `${postType} published`,
|
||||
color: 'positive',
|
||||
})
|
||||
} catch (e) {
|
||||
this.$q.notify({
|
||||
message: `Failed to publish post`,
|
||||
color: 'negative'
|
||||
})
|
||||
}
|
||||
this.publishing = false
|
||||
},
|
||||
buildTags() {
|
||||
// FIXME
|
||||
const e = []
|
||||
const p = []
|
||||
for (const {id, pubkey} of this.replyTo) {
|
||||
e.push(['e', id])
|
||||
if (pubkey) {
|
||||
p.push(['p', pubkey])
|
||||
}
|
||||
}
|
||||
return e.concat(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
|
||||
button.btn {
|
||||
cursor: pointer;
|
||||
background-color: $color-primary;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
padding: 8px 16px;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
height: fit-content;
|
||||
&:disabled {
|
||||
cursor: no-drop;
|
||||
background-color: rgba($color: $color-primary, $alpha: 0.3);
|
||||
color: rgba($color: #fff, $alpha: 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.post-editor {
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
&-author {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.connector-top {
|
||||
height: 1rem;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.connector-line {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
background: rgb(56, 68, 77);
|
||||
}
|
||||
}
|
||||
&-content {
|
||||
margin-left: 12px;
|
||||
width: 100%;
|
||||
.input-section {
|
||||
width: 100%;
|
||||
textarea {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: block;
|
||||
padding: 12px;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.3em;
|
||||
resize: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
min-height: 5rem;
|
||||
max-height: 30rem;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.controls {
|
||||
border-top: $border-dark;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
&-media {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
&-item {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
cursor:pointer;
|
||||
padding: 5px;
|
||||
svg {
|
||||
width: 100%;
|
||||
fill: $color-primary
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-primary, $alpha: 0.3);
|
||||
}
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
svg {
|
||||
fill: rgba($color: $color-primary, $alpha: 0.5);
|
||||
}
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&-fake-submit {
|
||||
height: fit-content;
|
||||
margin: auto;
|
||||
}
|
||||
&.compact {
|
||||
.input-section {
|
||||
textarea {
|
||||
padding: 10px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
.controls {
|
||||
border-top: 0;
|
||||
padding: 0;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
.input-section textarea {
|
||||
min-height: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.controls {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.connector {
|
||||
.post-editor-content {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.post-editor-fake-submit {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
152
src/components/Logo.vue
Normal file
152
src/components/Logo.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve"
|
||||
>
|
||||
<circle fill="none" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round" stroke-miterlimit="10" cx="130.405" cy="125.885" r="106.182"/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="none" stroke="#FFFFFF" stroke-width="10" stroke-miterlimit="10" d="
|
||||
M89.046,28.06c-4.694-7.709-13.178-12.857-22.864-12.857c-14.773,0-26.75,11.977-26.75,26.75c0,7.152,2.813,13.645,7.388,18.445
|
||||
C57.853,46.334,72.358,35.124,89.046,28.06z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="none" stroke="#FFFFFF" stroke-width="10" stroke-miterlimit="10" d="
|
||||
M215.075,61.81c5.421-4.896,8.829-11.979,8.829-19.857c0-14.773-11.977-26.75-26.75-26.75c-10.05,0-18.801,5.545-23.372,13.74
|
||||
C190.169,36.286,204.353,47.663,215.075,61.81z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="none" stroke="#FFFFFF" stroke-width="9" stroke-linecap="round" stroke-miterlimit="10" d="
|
||||
M76.478,167.878c2.961,8.34,31.842,30.611,52.489,0.166c0,0,27.432,42.181,55.367-3.615"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF" stroke="#FFFFFF" stroke-width="9" stroke-miterlimit="10" d="
|
||||
M116.508,148.768c0.071-0.187,0.206-0.355,0.412-0.496c3.146-2.154,8.273-1.959,11.896-1.987c3.975-0.025,8.085,0.455,11.642,2.354
|
||||
c0.395,0.211,4.308,3.19,3.713,3.722c-0.009,0.007-15.275,13.585-15.275,13.585c-3.139-4.181-6.072-8.683-9.537-12.593
|
||||
C118.651,152.557,115.958,150.146,116.508,148.768z"
|
||||
/>
|
||||
<path
|
||||
fill="none" stroke="#FFFFFF" stroke-width="9" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="
|
||||
M128.967,168.044C132.191,155.438,128.967,168.044,128.967,168.044l0.382,0.558c9.353,13.53,23.145,16.836,23.145,16.836v28.613
|
||||
h-44.175v-30.889C108.318,183.162,125.741,180.649,128.967,168.044"
|
||||
/>
|
||||
<line fill="none" stroke="#FFFFFF" stroke-width="9" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" x1="130.405" y1="180.969" x2="130.405" y2="214.051"/>
|
||||
<line fill="none" stroke="#FFFFFF" stroke-width="9.194" stroke-linecap="round" stroke-miterlimit="10" x1="214.753" y1="160.661" x2="249.053" y2="160.596"/>
|
||||
<line fill="none" stroke="#FFFFFF" stroke-width="9.194" stroke-linecap="round" stroke-miterlimit="10" x1="214.753" y1="149.051" x2="248.525" y2="139.352"/>
|
||||
<line fill="none" stroke="#FFFFFF" stroke-width="9.194" stroke-linecap="round" stroke-miterlimit="10" x1="39.707" y1="158.733" x2="8.33" y2="158.561"/>
|
||||
<line fill="none" stroke="#FFFFFF" stroke-width="9.194" stroke-linecap="round" stroke-miterlimit="10" x1="39.88" y1="142.173" x2="7.831" y2="130.366"/>
|
||||
<g>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" stroke="#FFFFFF" stroke-miterlimit="10" d="M155.362,88.202
|
||||
c-0.029-4.951,2.224-8.636,5.848-9.566c4.301-1.105,8.552,0.895,10.979,5.197c1.531,2.714,2.24,5.572,1.691,8.727
|
||||
c-0.785,4.493-3.134,7.206-6.826,7.651c-4.464,0.542-8.804-1.843-10.388-5.798C155.872,92.432,155.238,90.39,155.362,88.202z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" stroke="#FFFFFF" stroke-miterlimit="10" d="M106.092,88.26
|
||||
c-0.04,7.421-4.563,12.313-11.063,12.006c-2.279-0.107-4.125-1.061-5.514-2.809c-4.031-5.081-1.825-14.98,3.965-17.956
|
||||
C100.129,76.085,106.136,80.257,106.092,88.26z"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF" stroke="#FFFFFF" stroke-miterlimit="10" d="M33.286,72.474
|
||||
c0.138,0.026,0.275,0.064,0.413,0.08c3.855,0.466,7.085,0.104,9.17-4.062c1.471-2.937,4.618-4.612,7.302-6.525
|
||||
c5.489-2.794,11.236-4.058,17.405-3.07c2.778,0.446,5.37,1.405,7.924,2.519c2.792,2.517,5.598,3.234,8.434,0.063
|
||||
c6.087-2.794,12.366-3.767,18.954-2.082c2.237,0.573,4.305,1.536,6.379,2.504c4.273,2.675,7.911,5.976,10.248,10.526
|
||||
c0.456,0.885,0.87,0.838,1.493,0.212c1.448-1.464,3.333-2.207,5.144-3.071c2.812-0.648,5.621-0.653,8.432,0.003
|
||||
c1.801,0.878,3.722,1.566,5.136,3.072c0.829,0.883,1.201,0.371,1.604-0.398c2.331-4.475,5.943-7.697,10.148-10.337
|
||||
c8.372-4.41,16.817-4.365,25.33-0.426c2.552,2.556,5.168,3.074,7.9,0.261c0.14-0.145,0.354-0.215,0.535-0.32
|
||||
c8.533-4.038,16.975-3.74,25.327,0.547c1.682,1.266,3.362,2.531,5.044,3.798c1.132,1.269,2.262,2.539,3.394,3.809
|
||||
c0.744,2.435,2.353,3.389,4.879,3.012c1.169-0.175,2.378-0.085,3.568-0.114c2.793,0.073,5.584,0.147,8.377,0.222
|
||||
c0.878,0.277,1.509,0.909,2.16,1.51c1.011,3.271-0.235,5.077-3.601,5.126c-3.164,0.047-6.332,0.066-9.496,0.012
|
||||
c-1.215-0.021-1.53,0.255-1.318,1.553c0.359,2.209,0.517,4.438,0.313,6.719c-0.372,4.168-1.711,8.042-3.13,11.915
|
||||
c-2.968,6.08-6.724,11.629-11.236,16.67c-6.991,7.812-15.141,14.158-24.271,19.271c-2.802,1.804-5.616,2.109-8.453,0.078
|
||||
c-2.049-1.208-4.148-2.336-6.137-3.635c-9.803-6.399-18.122-14.35-24.862-23.935c-0.908-1.439-1.819-2.877-2.728-4.315
|
||||
c-0.473-1.497-1.251-2.845-2.067-4.17c-0.891-2.804-1.778-5.609-2.669-8.413c-0.185-1.838-0.369-3.676-0.555-5.513
|
||||
c0.075-1.118,0.295-2.247-0.24-3.32c-0.067-0.44-0.132-0.879-0.199-1.317c-0.072-0.127-0.146-0.252-0.221-0.377
|
||||
c-0.053-0.697-0.398-1.235-0.899-1.691c-0.334-0.753-0.901-1.264-1.634-1.614c-3.231-2.17-6.494-1.904-9.267,0.753
|
||||
c-0.143,0.144-0.287,0.289-0.431,0.431c-0.678,0.362-0.891,1.038-1.15,1.685c-0.037,0.146-0.074,0.291-0.113,0.438
|
||||
c-0.071,0.124-0.142,0.247-0.214,0.372c-0.273,0.409-0.273,0.861-0.204,1.323c-0.562,1.214-0.279,2.487-0.271,3.741
|
||||
c-0.175,1.696-0.349,3.395-0.523,5.09c-0.893,2.804-1.782,5.608-2.675,8.411c-7.996,15.925-20.401,27.471-35.79,36.056
|
||||
c-2.836,2.02-5.651,1.732-8.451-0.078c-3.869-2.267-7.689-4.601-11.276-7.311c-7.569-5.714-14.02-12.499-19.54-20.188
|
||||
c-0.805-1.283-1.608-2.566-2.412-3.849c-0.763-1.531-1.524-3.062-2.286-4.591c-1.07-2.954-2.136-5.912-2.83-8.985
|
||||
c-0.155-2.194-0.312-4.392-0.467-6.586c0.156-1.109,0.206-2.248,0.498-3.32c0.32-1.173-0.192-1.296-1.139-1.283
|
||||
c-3.443,0.043-6.887,0.043-10.331,0.06c-0.288-0.025-0.574-0.052-0.861-0.079c-2.538-1.444-2.932-2.407-2.098-5.13
|
||||
c0.643-0.614,1.29-1.224,2.164-1.507C27.704,72.623,30.495,72.548,33.286,72.474z M60.718,65.737
|
||||
c-3.646,0.192-6.773,1.734-9.528,3.973c-7.521,6.104-9.672,15.87-5.484,25.563c5.931,13.73,16.499,23.275,28.545,31.569
|
||||
c3.982,2.742,7.387,3.218,11.086-0.001c0.104-0.092,0.2-0.194,0.299-0.291c4.475-2.675,8.503-5.938,12.303-9.48
|
||||
c5.988-5.585,11.31-11.714,14.898-19.163c3.644-7.562,4.756-15.139,0.347-22.891c-5.691-10.006-20.171-12.782-29.021-5.45
|
||||
c-4.24,3.513-4.337,3.515-8.524,0.138c-0.219-0.175-0.426-0.37-0.657-0.528C70.659,66.236,66.007,64.667,60.718,65.737z
|
||||
M186.579,126.59c1.366-0.456,2.384-1.469,3.508-2.284c9.613-6.957,17.975-15.089,23.47-25.78
|
||||
c3.94-7.666,5.265-15.471,0.734-23.453c-3.747-6.604-11.905-10.522-19.457-9.49c-4.192,0.573-7.718,2.317-10.871,5.043
|
||||
c-2.409,2.084-3.592,2.052-6.099,0.042c-0.495-0.396-0.944-0.849-1.455-1.219c-4.31-3.126-9.022-4.722-14.401-3.757
|
||||
c-6.682,0.359-13.72,6.04-16.079,12.732c-2.527,7.175-1.024,13.873,2.394,20.227c7.302,13.578,18.4,23.34,31.689,30.807
|
||||
c0.593,0.332,1.076,0.445,1.723,0.03C183.316,128.47,184.961,127.549,186.579,126.59z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="#EE517D" d="M162.008,65.691c5.379-0.965,10.092,0.631,14.401,3.757
|
||||
c0.511,0.37,0.96,0.823,1.455,1.219c2.507,2.01,3.689,2.042,6.099-0.042c3.153-2.726,6.679-4.47,10.871-5.043
|
||||
c7.552-1.032,15.71,2.887,19.457,9.49c4.53,7.982,3.206,15.787-0.734,23.453c-5.495,10.691-13.856,18.823-23.47,25.78
|
||||
c-1.124,0.815-2.142,1.828-3.508,2.284c-0.529-0.329-0.39-0.776-0.317-1.292c0.393-2.838-0.277-5.356-2.277-7.499
|
||||
c-3.718-3.984-7.773-7.629-11.228-11.887c-5.275-6.507-9.635-13.459-11.346-21.744c-0.603-2.923-0.711-6.06,0.152-8.97
|
||||
C162.508,72.015,163.64,68.949,162.008,65.691z M204.23,89.702c-1.404-0.044-2.469,0.577-3.114,1.777
|
||||
c-3.574,6.656-8.497,12.18-14.145,17.128c-1.765,1.546-1.896,3.829-0.547,5.217c1.378,1.418,3.433,1.298,5.256-0.299
|
||||
c3.073-2.689,5.982-5.55,8.605-8.677c2.576-3.069,4.848-6.368,6.652-9.956C208.232,92.325,206.852,89.757,204.23,89.702z
|
||||
M197.83,72.979c-1.985-0.129-3.531,1.04-3.762,2.839c-0.262,2.039,0.841,3.614,2.902,3.843c2.07,0.23,3.68,1.063,5.008,2.69
|
||||
c1.407,1.723,3.253,1.892,4.77,0.65c1.545-1.264,1.734-3.081,0.479-4.833C204.923,74.951,201.736,73.312,197.83,72.979z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="#EE517D" d="M60.718,65.737c5.289-1.07,9.941,0.499,14.263,3.438
|
||||
c0.231,0.158,0.438,0.354,0.657,0.528c4.188,3.377,4.284,3.375,8.524-0.138c8.85-7.332,23.329-4.556,29.021,5.45
|
||||
c4.409,7.752,3.297,15.329-0.347,22.891c-3.589,7.449-8.91,13.578-14.898,19.163c-3.8,3.543-7.828,6.806-12.303,9.48
|
||||
c-0.618-0.47-0.467-1.156-0.415-1.756c0.298-3.43-1.079-6.052-3.541-8.381c-6.019-5.698-11.818-11.605-15.967-18.895
|
||||
c-4.395-7.718-7.619-15.699-4.493-24.726C62.105,70.233,61.799,68.024,60.718,65.737z M106.341,93.119
|
||||
c0.052-1.684-0.739-2.791-2.214-3.253c-1.52-0.475-2.927-0.135-3.852,1.288c-1.333,2.053-2.546,4.186-3.93,6.201
|
||||
c-2.918,4.249-6.644,7.764-10.4,11.25c-1.863,1.73-2.038,3.583-0.632,5.145c1.287,1.431,3.352,1.491,5.069-0.013
|
||||
c6.105-5.342,11.513-11.274,15.342-18.506C106.086,94.55,106.438,93.859,106.341,93.119z M95.963,72.926
|
||||
c-1.254-0.055-2.896,1.269-3.094,3.13c-0.183,1.718,1.115,3.389,3.057,3.598c2.175,0.234,3.736,1.271,5.122,2.904
|
||||
c1.304,1.537,3.229,1.611,4.67,0.44c1.435-1.167,1.715-3.089,0.551-4.69C103.919,75.07,100.731,73.263,95.963,72.926z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="#D23E69" d="M162.008,65.691c1.632,3.258,0.5,6.323-0.444,9.507
|
||||
c-0.863,2.91-0.755,6.047-0.152,8.97c1.711,8.285,6.07,15.237,11.346,21.744c3.454,4.258,7.51,7.902,11.228,11.887
|
||||
c2,2.143,2.67,4.661,2.277,7.499c-0.072,0.516-0.212,0.963,0.317,1.292c-1.618,0.959-3.263,1.88-4.845,2.897
|
||||
c-0.646,0.415-1.13,0.302-1.723-0.03c-13.289-7.467-24.388-17.229-31.689-30.807c-3.418-6.354-4.921-13.052-2.394-20.227
|
||||
C148.288,71.731,155.326,66.051,162.008,65.691z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="#D23E69" d="M60.718,65.737c1.081,2.287,1.388,4.496,0.501,7.057
|
||||
c-3.126,9.026,0.099,17.008,4.493,24.726c4.148,7.289,9.948,13.196,15.967,18.895c2.462,2.329,3.839,4.951,3.541,8.381
|
||||
c-0.052,0.6-0.203,1.286,0.415,1.756c-0.099,0.097-0.194,0.199-0.299,0.291c-3.699,3.219-7.104,2.743-11.086,0.001
|
||||
c-12.046-8.294-22.614-17.839-28.545-31.569c-4.188-9.693-2.037-19.46,5.484-25.563C53.944,67.472,57.071,65.93,60.718,65.737z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF" stroke="#FFFFFF" stroke-miterlimit="10" d="M204.23,89.702
|
||||
c2.621,0.055,4.002,2.623,2.708,5.19c-1.805,3.588-4.076,6.887-6.652,9.956c-2.623,3.127-5.532,5.987-8.605,8.677
|
||||
c-1.823,1.597-3.878,1.717-5.256,0.299c-1.349-1.388-1.218-3.671,0.547-5.217c5.647-4.948,10.57-10.472,14.145-17.128
|
||||
C201.762,90.279,202.826,89.658,204.23,89.702z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF" stroke="#FFFFFF" stroke-miterlimit="10" d="M197.83,72.979
|
||||
c3.906,0.332,7.093,1.972,9.396,5.189c1.256,1.752,1.066,3.569-0.479,4.833c-1.517,1.241-3.362,1.072-4.77-0.65
|
||||
c-1.328-1.627-2.938-2.46-5.008-2.69c-2.062-0.229-3.164-1.804-2.902-3.843C194.299,74.02,195.845,72.851,197.83,72.979z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF" stroke="#FFFFFF" stroke-miterlimit="10" d="M106.341,93.119
|
||||
c0.098,0.74-0.255,1.431-0.616,2.112c-3.829,7.231-9.236,13.164-15.342,18.506c-1.718,1.504-3.782,1.443-5.069,0.013
|
||||
c-1.406-1.562-1.231-3.414,0.632-5.145c3.757-3.486,7.482-7.001,10.4-11.25c1.384-2.016,2.597-4.148,3.93-6.201
|
||||
c0.925-1.423,2.332-1.763,3.852-1.288C105.602,90.328,106.393,91.436,106.341,93.119z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF" stroke="#FFFFFF" stroke-miterlimit="10" d="M95.963,72.926
|
||||
c4.769,0.337,7.956,2.145,10.306,5.382c1.164,1.602,0.884,3.523-0.551,4.69c-1.44,1.171-3.366,1.097-4.67-0.44
|
||||
c-1.386-1.633-2.947-2.67-5.122-2.904c-1.941-0.209-3.239-1.88-3.057-3.598C93.066,74.194,94.709,72.871,95.963,72.926z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Logo',
|
||||
}
|
||||
</script>
|
244
src/components/MainMenu/MainMenu.vue
Normal file
244
src/components/MainMenu/MainMenu.vue
Normal file
@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<menu>
|
||||
<div class="menu-nav">
|
||||
<div class="menu-logo">
|
||||
<router-link to="/">
|
||||
<Logo />
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-for="(route, i) in items" :key="i">
|
||||
<MenuItem
|
||||
v-if="!hideItemsRequiringSignIn || !route.signInRequired || app.isSignedIn"
|
||||
:icon="route.name.toLowerCase()"
|
||||
:to="route.path"
|
||||
:enabled="route.enabled !== false"
|
||||
@click="$emit('mobile-menu-close')"
|
||||
>
|
||||
{{ route.name }}
|
||||
</MenuItem>
|
||||
</div>
|
||||
<MenuItem
|
||||
v-if="!hideItemsRequiringSignIn || app.isSignedIn"
|
||||
icon="profile"
|
||||
:to="`/profile/${app.myPubkey}`"
|
||||
:enabled="app.isSignedIn"
|
||||
@click="$emit('mobile-menu-close')"
|
||||
>
|
||||
Profile
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon="settings"
|
||||
to="/settings"
|
||||
@click="$emit('mobile-menu-close')"
|
||||
>
|
||||
Settings
|
||||
</MenuItem>
|
||||
|
||||
<div
|
||||
v-if="!hideItemsRequiringSignIn || app.isSignedIn"
|
||||
class="menu-post-button"
|
||||
@click="createPost"
|
||||
>
|
||||
<span class="label">Post</span>
|
||||
<BaseIcon class="icon" icon="pen" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position: sticky; bottom: 0">
|
||||
<ProfilePopup v-if="app.isSignedIn" />
|
||||
<div v-else class="sign-in" @click="signIn">
|
||||
<q-icon class="icon" name="login" size="sm" />
|
||||
<div class="label">Log in</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mobile-close-menu-button"
|
||||
@click="$emit('mobile-menu-close')"
|
||||
>
|
||||
<div class="icon">
|
||||
<BaseIcon icon="left" />
|
||||
</div>
|
||||
<span>Close</span>
|
||||
</div>
|
||||
</menu>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MenuItem from 'components/MainMenu/MenuItem.vue'
|
||||
import BaseIcon from 'components/BaseIcon'
|
||||
import ProfilePopup from 'components/MainMenu/ProfilePopup'
|
||||
import Logo from 'components/Logo.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {MENU_ITEMS} from 'components/MainMenu/constants.js'
|
||||
|
||||
export default {
|
||||
name: 'MainMenu',
|
||||
components: {
|
||||
Logo,
|
||||
MenuItem,
|
||||
BaseIcon,
|
||||
ProfilePopup
|
||||
},
|
||||
props: {
|
||||
hideItemsRequiringSignIn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
emits: ['mobile-menu-close'],
|
||||
data() {
|
||||
return {
|
||||
items: MENU_ITEMS,
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
app: useAppStore(),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createPost() {
|
||||
this.$emit('mobile-menu-close')
|
||||
this.app.createPost()
|
||||
},
|
||||
signIn() {
|
||||
this.$emit('mobile-menu-close')
|
||||
this.app.signIn()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "assets/theme/colors.scss";
|
||||
@import "assets/variables.scss";
|
||||
|
||||
menu {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
padding-inline-start: 0;
|
||||
.menu {
|
||||
height: 100%;
|
||||
&-nav {
|
||||
position: relative;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
&-logo {
|
||||
margin: 1rem 0;
|
||||
svg, img {
|
||||
display: block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
svg {
|
||||
circle {
|
||||
fill: transparent;
|
||||
transition: fill 200ms ease-in-out;
|
||||
}
|
||||
&:hover circle {
|
||||
fill: rgba($color: $color-primary, $alpha: 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
&-post-button {
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
cursor: pointer;
|
||||
background-color: $color-primary;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
border-radius: 999px;
|
||||
margin-top: 20px;
|
||||
.icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.mobile-close-menu-button {
|
||||
display: none;
|
||||
}
|
||||
.sign-in {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 1rem 1rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
transition: 120ms ease-in-out;
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
|
||||
}
|
||||
.icon {
|
||||
padding: 2px;
|
||||
}
|
||||
.label {
|
||||
margin-left: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) and (min-width: $phone) {
|
||||
menu {
|
||||
align-items: flex-end;
|
||||
.menu-post-button {
|
||||
width: fit-content;
|
||||
padding: 1rem;
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
.icon {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
.sign-in {
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $phone) {
|
||||
menu {
|
||||
.mobile-close-menu-button {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
border-radius: 999px;
|
||||
background-color: $color-primary;
|
||||
cursor: pointer;
|
||||
.icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
svg {
|
||||
fill: #fff;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
span {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
margin: 0 4px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
118
src/components/MainMenu/MenuItem.vue
Normal file
118
src/components/MainMenu/MenuItem.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<component
|
||||
:is="enabled ? 'router-link' : 'div'"
|
||||
:to="enabled ? to : ''"
|
||||
@click="enabled && $emit('click')"
|
||||
>
|
||||
<div class="menu-item">
|
||||
<div class="menu-item-logo">
|
||||
<base-icon
|
||||
:icon="icon"
|
||||
:icon-color="iconColor"
|
||||
/>
|
||||
</div>
|
||||
<div class="menu-item-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseIcon from 'components/BaseIcon'
|
||||
|
||||
export default {
|
||||
name: 'MenuItem',
|
||||
components: {
|
||||
BaseIcon
|
||||
},
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'more'
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: '#fff'
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
emits: ['click'],
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
@import "assets/variables.scss";
|
||||
|
||||
a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
border-radius: 999px;
|
||||
transition: 120ms ease-in-out;
|
||||
color: #fff;
|
||||
&-logo {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
svg {
|
||||
transition: 20ms ease-in-out fill;
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
&-content {
|
||||
transition: 20ms ease-in-out color;
|
||||
margin: 0 20px;
|
||||
color: inherit;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-primary, $alpha: 0.2);
|
||||
& {
|
||||
color: $color-primary;
|
||||
}
|
||||
svg {
|
||||
fill: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.router-link-active, .router-link-exact-active {
|
||||
.menu-item {
|
||||
&-logo {
|
||||
svg {
|
||||
fill: $color-primary;
|
||||
}
|
||||
}
|
||||
&-content {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) and (min-width: $phone) {
|
||||
.menu-item {
|
||||
&-content {
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
71
src/components/MainMenu/MoreMenu.vue
Normal file
71
src/components/MainMenu/MoreMenu.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="more-menu">
|
||||
<div
|
||||
v-for="item in moreMenuItems"
|
||||
:key="item.name"
|
||||
class="more-menu-item"
|
||||
>
|
||||
<div class="icon">
|
||||
<base-icon :icon="item.icon" />
|
||||
</div>
|
||||
<div class="content">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseIcon from 'components/BaseIcon'
|
||||
import { MORE_MENU_ITEMS } from 'components/MainMenu/constants.js'
|
||||
|
||||
export default {
|
||||
name: 'MoreMenu',
|
||||
components: {
|
||||
BaseIcon
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
moreMenuItems: MORE_MENU_ITEMS
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'assets/theme/colors.scss';
|
||||
|
||||
.more-menu {
|
||||
position: absolute;
|
||||
bottom: 54px;
|
||||
left: 0;
|
||||
width: 260px;
|
||||
padding: 10px;
|
||||
background-color: $color-bg;
|
||||
box-shadow: $shadow-white;
|
||||
border-radius: 5px;
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 10px 5px;
|
||||
& + & {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.icon {
|
||||
width: 1.5rem;
|
||||
svg {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
.content {
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-left: 10px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
217
src/components/MainMenu/ProfilePopup.vue
Normal file
217
src/components/MainMenu/ProfilePopup.vue
Normal file
@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="menu-profile-wrapper">
|
||||
<div class="menu-profile">
|
||||
<div class="menu-profile-pic">
|
||||
<UserAvatar :pubkey="pubkey" :clickable="false" />
|
||||
</div>
|
||||
<div class="menu-profile-items">
|
||||
<div class="profile-info">
|
||||
<p>
|
||||
<UserName :pubkey="pubkey" :clickable="false" two-line />
|
||||
</p>
|
||||
</div>
|
||||
<div class="more">
|
||||
<BaseIcon icon="more" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<q-menu :offset="[0, 20]" target=".menu-profile" class="menu-profile-popup" >
|
||||
<div>
|
||||
<div v-for="(_, pk) in $store.state.accounts" :key="pk" class="popup-header" @click="switchAccount(pk)">
|
||||
<div class="sidebar-profile-pic">
|
||||
<UserAvatar :pubkey="pk" :clickable="false"/>
|
||||
</div>
|
||||
<div class="menu-profile-items">
|
||||
<div class="profile-info">
|
||||
<p>
|
||||
<UserName :pubkey="pk" :clickable="false" two-line />
|
||||
</p>
|
||||
</div>
|
||||
<div class="more" v-if="pk === pubkey">
|
||||
<BaseIcon icon="tick" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="popup-spacing">
|
||||
<div class="popup-body">
|
||||
<div class="popup-body-item" @click="$store.dispatch('signIn', {}).catch(() => {})" v-close-popup>
|
||||
<p>Add an account</p>
|
||||
</div>
|
||||
<hr class="popup-spacing">
|
||||
<div
|
||||
class="popup-body-item"
|
||||
@click="handleLogOut"
|
||||
>
|
||||
<p>Logout from <span>{{ $store.getters.displayName(pubkey) }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseIcon from 'components/BaseIcon/index.vue'
|
||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import UserName from 'components/User/UserName.vue'
|
||||
|
||||
export default {
|
||||
name: 'ProfilePopup',
|
||||
components: {
|
||||
UserName,
|
||||
UserAvatar,
|
||||
BaseIcon
|
||||
},
|
||||
computed: {
|
||||
pubkey() {
|
||||
return this.$store.getters.myPubkey
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchAccount(pubkey) {
|
||||
const account = this.$store.state.accounts[pubkey]
|
||||
if (!account) return
|
||||
this.$store.commit('setKeys', {
|
||||
priv: account.secret,
|
||||
pub: pubkey
|
||||
})
|
||||
this.$store.dispatch('useProfile', {pubkey})
|
||||
},
|
||||
handleLogOut() {
|
||||
this.$store.dispatch('setLogOut')
|
||||
this.$router.push('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "assets/theme/colors.scss";
|
||||
@import "assets/variables.scss";
|
||||
|
||||
.menu-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
margin-right: 1rem;
|
||||
padding: .5rem 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
transition: 120ms ease-in-out;
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
|
||||
}
|
||||
&-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
&-pic {
|
||||
padding: 2px;
|
||||
img {
|
||||
border-radius: 999px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
&-items {
|
||||
margin-left: 12px;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.profile-info {
|
||||
user-select: none;
|
||||
p {
|
||||
margin: 0;
|
||||
& + p {
|
||||
margin-top: 5px;
|
||||
}
|
||||
color: #fff;
|
||||
&.nickname {
|
||||
color: $color-dark-gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
.more {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
svg {
|
||||
width: 100%;
|
||||
fill: #fff;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-popup {
|
||||
width: 300px;
|
||||
border-radius: 1rem;
|
||||
padding: 10px;
|
||||
background-color: $color-bg;
|
||||
box-shadow: $shadow-white;
|
||||
.popup-spacing {
|
||||
border: none;
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.2);
|
||||
padding-top: 2px;
|
||||
margin: 3px;
|
||||
}
|
||||
.popup-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
|
||||
}
|
||||
.more {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
svg {
|
||||
fill: $color-primary;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.popup-body {
|
||||
&-item {
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
span {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) and (min-width: $phone) {
|
||||
.menu-profile {
|
||||
padding: 4px;
|
||||
margin: auto;
|
||||
&-wrapper {
|
||||
padding: 0;
|
||||
margin: 0 10px 1rem auto;
|
||||
}
|
||||
> .menu-profile-items {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-width: $phone) {
|
||||
.menu-profile {
|
||||
padding: .5rem;
|
||||
margin: 0 auto 1rem auto;
|
||||
&-wrapper {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
48
src/components/MainMenu/constants.js
Normal file
48
src/components/MainMenu/constants.js
Normal file
@ -0,0 +1,48 @@
|
||||
export const MENU_ITEMS = [
|
||||
{
|
||||
name: 'Home',
|
||||
path: '/home',
|
||||
},
|
||||
// {
|
||||
// name: 'Explore',
|
||||
// path: '/explore',
|
||||
// },
|
||||
{
|
||||
name: 'Notifications',
|
||||
path: '/notifications',
|
||||
signInRequired: true,
|
||||
},
|
||||
{
|
||||
name: 'Messages',
|
||||
path: '/messages/inbox',
|
||||
signInRequired: true,
|
||||
},
|
||||
// {
|
||||
// name: 'Settings',
|
||||
// path: '/settings',
|
||||
// req: true,
|
||||
// },
|
||||
// {
|
||||
// name: 'Bookmarks',
|
||||
// path: '/bookmarks'
|
||||
// },
|
||||
]
|
||||
|
||||
export const MORE_MENU_ITEMS = [
|
||||
{
|
||||
name: 'Topics',
|
||||
icon: 'topics'
|
||||
},
|
||||
{
|
||||
name: 'Moments',
|
||||
icon: 'moments'
|
||||
},
|
||||
{
|
||||
name: 'Help Center',
|
||||
icon: 'help'
|
||||
},
|
||||
{
|
||||
name: 'Settings & privacy',
|
||||
icon: 'settings'
|
||||
},
|
||||
]
|
124
src/components/PageHeader.vue
Normal file
124
src/components/PageHeader.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div
|
||||
v-if="backButton"
|
||||
class="back-button"
|
||||
@click="$router.go(-1)"
|
||||
>
|
||||
<base-icon icon="back" />
|
||||
</div>
|
||||
<div :class="{'profile-info': !!subline}">
|
||||
<h2>{{ title || titleFromRoute() || 'Home' }}</h2>
|
||||
<span v-if="subline">{{ subline }}</span>
|
||||
</div>
|
||||
<div class="addon">
|
||||
<slot />
|
||||
</div>
|
||||
<router-link class="logo" to="/">
|
||||
<Logo />
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import BaseIcon from 'components/BaseIcon/index.vue'
|
||||
import Logo from 'components/Logo.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageHeader',
|
||||
components: {
|
||||
Logo,
|
||||
BaseIcon
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
subline: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
backButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
titleFromRoute() {
|
||||
const route = this.$route.name?.toLowerCase()
|
||||
return route.charAt(0).toUpperCase() + route.substring(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "assets/theme/colors.scss";
|
||||
@import "assets/variables.scss";
|
||||
|
||||
.page-header {
|
||||
padding: 1rem;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: $color-bg;
|
||||
z-index: 500;
|
||||
h2 {
|
||||
line-height: 50px;
|
||||
margin: 0;
|
||||
}
|
||||
.back-button {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
margin-right: 20px;
|
||||
padding: 6px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-primary, $alpha: 0.3);
|
||||
}
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateX(-3px);
|
||||
fill: $color-primary;
|
||||
}
|
||||
}
|
||||
.profile-info {
|
||||
h2 {
|
||||
margin: 0 0 3px;
|
||||
}
|
||||
span {
|
||||
color: $color-dark-gray;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.logo {
|
||||
display: none;
|
||||
}
|
||||
.addon {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $phone) {
|
||||
.page-header {
|
||||
padding: .4rem 1rem;
|
||||
.logo {
|
||||
display: block;
|
||||
position: absolute;
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
left: calc(50% - 18px);
|
||||
svg {
|
||||
height: inherit;
|
||||
width: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
293
src/components/Post/HeroPost.vue
Normal file
293
src/components/Post/HeroPost.vue
Normal file
@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div class="post">
|
||||
<div class="post-author">
|
||||
<div class="post-author-avatar">
|
||||
<div class="connector-top">
|
||||
<div v-if="connector" class="connector-line"></div>
|
||||
</div>
|
||||
<UserAvatar :pubkey="post.author" />
|
||||
</div>
|
||||
<div class="post-author-name">
|
||||
<UserName :pubkey="post.author" two-line />
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<div class="post-content-header">
|
||||
<p v-if="post.inReplyTo" class="in-reply-to">
|
||||
Replying to <a @click.stop="linkToEvent(post.inReplyTo)">{{ shorten(post.inReplyTo) }}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="post-content-body">
|
||||
<p>
|
||||
<!-- <BaseMarkdown :content="post.content" />-->
|
||||
</p>
|
||||
</div>
|
||||
<div class="post-content-footer">
|
||||
<p class="created-at">
|
||||
<span>{{ formatPostTime(post.createdAt) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatPostDate(post.createdAt) }}</span>
|
||||
</p>
|
||||
<div class="post-content-actions">
|
||||
<div class="action-item comment">
|
||||
<BaseIcon icon="comment" />
|
||||
<span>{{ numComments }}</span>
|
||||
</div>
|
||||
<div class="action-item repost">
|
||||
<BaseIcon icon="repost" />
|
||||
<span>{{ post.stats.reposts }}</span>
|
||||
</div>
|
||||
<div class="action-item like">
|
||||
<BaseIcon icon="like" />
|
||||
<span>{{ post.stats.likes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-reply">
|
||||
<PostEditor
|
||||
compact
|
||||
placeholder="Post your reply"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseIcon from 'components/BaseIcon'
|
||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import UserName from 'components/User/UserName.vue'
|
||||
// import BaseMarkdown from 'app/tmp/BaseMarkdown.vue'
|
||||
import PostEditor from 'components/CreatePost/PostEditor.vue'
|
||||
import routerMixin from 'src/router/mixin'
|
||||
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||
|
||||
function countRepliesRecursive(event) {
|
||||
if (!event.replies) {
|
||||
return 0
|
||||
}
|
||||
let count = 0
|
||||
for (const thread of event.replies) {
|
||||
if (!thread || !thread.length) {
|
||||
continue
|
||||
}
|
||||
count += thread.length
|
||||
for (const reply of thread) {
|
||||
count += countRepliesRecursive(reply)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function postFromEvent(event) {
|
||||
return {
|
||||
id: event.id,
|
||||
author: event.pubkey,
|
||||
createdAt: event.created_at * 1000,
|
||||
content: event.interpolated.text,
|
||||
inReplyTo: event.interpolated.replyEvents[event.interpolated.replyEvents.length - 1],
|
||||
images: [],
|
||||
stats: {
|
||||
comments: '',
|
||||
reposts: '',
|
||||
likes: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'HeroPost',
|
||||
mixins: [routerMixin],
|
||||
components: {
|
||||
// BaseMarkdown,
|
||||
UserName,
|
||||
UserAvatar,
|
||||
BaseIcon,
|
||||
PostEditor,
|
||||
},
|
||||
props: {
|
||||
event: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
connector: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
post: postFromEvent(this.event),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
numComments() {
|
||||
return countRepliesRecursive(this.event)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatPostDate(timestamp) {
|
||||
const date = new Date(timestamp)
|
||||
const month = this.$t(MONTHS[date.getMonth()])
|
||||
|
||||
const sameYear = date.getFullYear() === (new Date().getFullYear())
|
||||
const year = !sameYear ? ' ' + date.getFullYear() : ''
|
||||
|
||||
return `${date.getDate()} ${month}${year}`
|
||||
},
|
||||
formatPostTime(timestamp) {
|
||||
const date = new Date(timestamp)
|
||||
return `${date.getHours()}:${date.getMinutes()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
@import "assets/variables.scss";
|
||||
|
||||
.post {
|
||||
border-bottom: $border-dark;
|
||||
padding-bottom: 1rem;
|
||||
&-author {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0 1rem;
|
||||
&-name {
|
||||
margin-top: 1rem;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.connector-top {
|
||||
height: 1rem;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.connector-line {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
background: rgb(56, 68, 77);
|
||||
}
|
||||
}
|
||||
&-content {
|
||||
padding: 1rem;
|
||||
flex-grow: 1;
|
||||
max-width: 644px;
|
||||
&-header {
|
||||
p.in-reply-to {
|
||||
color: $color-dark-gray;
|
||||
margin: 0 0 8px;
|
||||
a {
|
||||
color: $color-primary;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&-body {
|
||||
p {
|
||||
color: #fff;
|
||||
font-size: 1.6em;
|
||||
line-height: 1.3em;
|
||||
}
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
&-footer {
|
||||
color: $color-dark-gray;
|
||||
border-bottom: $border-dark;
|
||||
p.created-at {
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
border-bottom: $border-dark;
|
||||
span + span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
padding: .5rem 0;
|
||||
margin: auto;
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
padding: 8px;
|
||||
border-radius: 999px;
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
fill: $color-light-gray;
|
||||
}
|
||||
span {
|
||||
color: $color-light-gray;
|
||||
line-height: 40px;
|
||||
font-weight: bold;
|
||||
}
|
||||
&:hover {
|
||||
&.comment {
|
||||
svg {
|
||||
fill: $post-action-blue;
|
||||
background-color: rgba($color: $post-action-blue, $alpha: 0.2);
|
||||
}
|
||||
span {
|
||||
color: $post-action-blue;
|
||||
}
|
||||
}
|
||||
&.repost {
|
||||
svg {
|
||||
fill: $post-action-green;
|
||||
background-color: rgba($color: $post-action-green, $alpha: 0.2);
|
||||
}
|
||||
span {
|
||||
color: $post-action-green;
|
||||
}
|
||||
}
|
||||
&.like {
|
||||
svg {
|
||||
fill: $post-action-red;
|
||||
background-color: rgba($color: $post-action-red, $alpha: 0.2);
|
||||
}
|
||||
span {
|
||||
color: $post-action-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $phone) {
|
||||
.post{
|
||||
&-content {
|
||||
&-header {
|
||||
span{
|
||||
display: none;
|
||||
}
|
||||
.created-at {
|
||||
display: block;
|
||||
color: rgba($color: $color-dark-gray, $alpha: 0.5);
|
||||
margin: 5px 0;
|
||||
}
|
||||
.nip05 {
|
||||
display: unset;
|
||||
color: $color-dark-gray;
|
||||
}
|
||||
}
|
||||
&-actions {
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
284
src/components/Post/ListPost.vue
Normal file
284
src/components/Post/ListPost.vue
Normal file
@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div
|
||||
class="post"
|
||||
:class="{clickable}"
|
||||
@click.stop="clickable && linkToEvent(note.id)"
|
||||
>
|
||||
<div class="post-author">
|
||||
<div class="connector-top">
|
||||
<div v-if="connectorTop" class="connector-line"></div>
|
||||
</div>
|
||||
<UserAvatar :pubkey="note.author" clickable @click.stop />
|
||||
<div class="connector-bottom">
|
||||
<div v-if="connectorBottom" class="connector-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<div class="post-content-header">
|
||||
<p>
|
||||
<UserName :pubkey="note.author" clickable @click.stop />
|
||||
<span>·</span>
|
||||
<span class="created-at">{{ formatPostDate(note.createdAt) }}</span>
|
||||
</p>
|
||||
<p v-if="note.isReply()" class="in-reply-to">
|
||||
Replying to
|
||||
<a @click.stop="linkToProfile(ancestor?.author)">
|
||||
<UserName v-if="ancestor?.author" :pubkey="ancestor?.author" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="post-content-body">
|
||||
<!-- <BaseMarkdown :content="note.content" />-->
|
||||
{{ note.content }}
|
||||
</div>
|
||||
<div v-if="actions" class="post-content-actions">
|
||||
<div class="action-item comment" @click.stop="app.createPost({ancestor: note.ancestor()})">
|
||||
<BaseIcon icon="comment" />
|
||||
<span>{{ stats.comments }}</span>
|
||||
</div>
|
||||
<div class="action-item repost" @click.stop>
|
||||
<BaseIcon icon="repost" />
|
||||
<span>{{ stats.shares }}</span>
|
||||
</div>
|
||||
<div class="action-item like" @click.stop>
|
||||
<BaseIcon icon="like" />
|
||||
<span>{{ stats.reactions }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseIcon from 'components/BaseIcon'
|
||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import UserName from 'components/User/UserName.vue'
|
||||
// import BaseMarkdown from 'app/tmp/BaseMarkdown.vue'
|
||||
// import Note from 'src/nostr/model/Note'
|
||||
import routerMixin from 'src/router/mixin'
|
||||
import moment from 'moment'
|
||||
import {useAppStore} from 'stores/App'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
|
||||
export default {
|
||||
name: 'ListPost',
|
||||
mixins: [routerMixin],
|
||||
components: {
|
||||
UserAvatar,
|
||||
UserName,
|
||||
// BaseMarkdown,
|
||||
BaseIcon,
|
||||
},
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
connectorTop: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
connectorBottom: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
actions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
app: useAppStore(),
|
||||
nostr: useNostrStore(),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
ancestor() {
|
||||
return this.note.isReply()
|
||||
? this.nostr.getNote(this.note.ancestor())
|
||||
: null
|
||||
},
|
||||
stats() {
|
||||
return {
|
||||
comments: 69,
|
||||
reactions: 420,
|
||||
shares: 4711,
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatPostDate(timestamp) {
|
||||
return this.$q.screen.lt.md
|
||||
? this.shortDateFromNow(timestamp)
|
||||
: moment(timestamp * 1000).fromNow()
|
||||
},
|
||||
shortDateFromNow(timestamp) {
|
||||
const now = Date.now()
|
||||
const diff = Math.round(Math.max(now - (timestamp * 1000), 0) / 1000)
|
||||
const formatDiff = (div, offset) => Math.max(Math.floor((diff + offset) / div), 1)
|
||||
|
||||
if (diff < 45) return `${formatDiff(1, 0)}s`
|
||||
if (diff < 60 * 45) return `${formatDiff(60, 15)}m`
|
||||
if (diff < 60 * 60 * 22) return `${formatDiff(60 * 60, 60 * 15)}h`
|
||||
if (diff < 60 * 60 * 24 * 26) return `${formatDiff(60 * 60 * 24, 60 * 60 * 2)}d`
|
||||
if (diff < 60 * 60 * 24 * 30 * 320) return `${formatDiff(60 * 60 * 24 * 30, 60 * 60 * 24 * 4)}mo`
|
||||
return `${formatDiff(60 * 60 * 24 * 30 * 365, 60 * 60 * 24 * 45)}y`
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
@import "assets/variables.scss";
|
||||
|
||||
.post {
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
transition: 100ms ease background-color;
|
||||
border-bottom: $border-dark;
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.2);
|
||||
}
|
||||
}
|
||||
&-author {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.connector-top {
|
||||
height: 1rem;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.connector-bottom {
|
||||
flex-grow: 1;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.connector-line {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
background: rgb(56, 68, 77);
|
||||
}
|
||||
}
|
||||
&-content {
|
||||
margin-left: 12px;
|
||||
padding: 1rem 0 .4rem;
|
||||
flex-grow: 1;
|
||||
max-width: 570px;
|
||||
&-header {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.in-reply-to {
|
||||
color: $color-dark-gray;
|
||||
a {
|
||||
color: $color-primary;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
p {
|
||||
&:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
&:last-child {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
> span {
|
||||
color: $color-dark-gray;
|
||||
&:first-child {
|
||||
color: #fff;
|
||||
}
|
||||
& + span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
&.nip05 {
|
||||
}
|
||||
&.created-at {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&-body {
|
||||
color: #fff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
&-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
margin-left: -9px;
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
padding: 8px;
|
||||
border-radius: 999px;
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
fill: $color-light-gray;
|
||||
}
|
||||
span {
|
||||
color: $color-light-gray;
|
||||
}
|
||||
&:hover {
|
||||
&.comment {
|
||||
svg {
|
||||
fill: $post-action-blue;
|
||||
background-color: rgba($color: $post-action-blue, $alpha: 0.2);
|
||||
}
|
||||
span {
|
||||
color: $post-action-blue;
|
||||
}
|
||||
}
|
||||
&.repost {
|
||||
svg {
|
||||
fill: $post-action-green;
|
||||
background-color: rgba($color: $post-action-green, $alpha: 0.2);
|
||||
}
|
||||
span {
|
||||
color: $post-action-green;
|
||||
}
|
||||
}
|
||||
&.like {
|
||||
svg {
|
||||
fill: $post-action-red;
|
||||
background-color: rgba($color: $post-action-red, $alpha: 0.2);
|
||||
}
|
||||
span {
|
||||
color: $post-action-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $phone) {
|
||||
.post {
|
||||
&-content {
|
||||
&-header {
|
||||
.nip05 {
|
||||
display: unset;
|
||||
color: $color-dark-gray;
|
||||
}
|
||||
}
|
||||
&-actions {
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
45
src/components/Post/Thread.vue
Normal file
45
src/components/Post/Thread.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="thread">
|
||||
<ListPost
|
||||
v-for="(note, index) in thread"
|
||||
:key="note.id"
|
||||
:note="note"
|
||||
:connector-top="thread.length > 1 && index > 0"
|
||||
:connector-bottom="(thread.length > 1 && index < thread.length - 1) || forceBottomConnector"
|
||||
actions
|
||||
clickable
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListPost from 'components/Post/ListPost.vue'
|
||||
|
||||
export default {
|
||||
name: 'Thread',
|
||||
components: {
|
||||
ListPost
|
||||
},
|
||||
props: {
|
||||
thread: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
forceBottomConnector: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
|
||||
.thread {
|
||||
border-bottom: $border-dark;
|
||||
.post {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
205
src/components/SearchBox/SearchBox.vue
Normal file
205
src/components/SearchBox/SearchBox.vue
Normal file
@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div
|
||||
class="searchbox"
|
||||
:class="{focused: isFocused}"
|
||||
>
|
||||
<div class="searchbox-wrapper">
|
||||
<div class="searchbox-icon">
|
||||
<BaseIcon icon="search" />
|
||||
</div>
|
||||
<div class="searchbox-input">
|
||||
<form @submit="searchProfile">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search profiles"
|
||||
v-model="query"
|
||||
@focus="toggleFocus"
|
||||
@blur="toggleFocus"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="domainMode">
|
||||
<div class="flex row justify-between no-wrap">
|
||||
<h2 class="text-h6 text-bold q-my-none"> {{ domain }} {{ $t('users') }}</h2>
|
||||
<q-btn icon="close" @click.stop="domainMode = false" />
|
||||
</div>
|
||||
<div v-if="domainDefaultPubkey">
|
||||
<h2 class="text-caption text-bold q-my-none"> {{ $t('nip05Maintainer') }} </h2>
|
||||
<BaseUserCard :pubkey="domainDefaultPubkey"/>
|
||||
</div>
|
||||
<q-list class="q-pt-xs q-pl-sm" style="overflow-y: auto; max-height: 40vh;">
|
||||
<div v-for="user in domainUsers" :key="user.pubkey">
|
||||
<BaseUserCard :pubkey="user.pubkey" />
|
||||
</div>
|
||||
</q-list>
|
||||
<q-separator color='accent' />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseIcon from 'components/BaseIcon'
|
||||
import {Notify} from 'quasar'
|
||||
import {searchDomain, queryName} from 'nostr-tools/nip05'
|
||||
import helpersMixin from 'src/utils/mixin'
|
||||
|
||||
export default {
|
||||
name: 'SearchBox',
|
||||
components: {
|
||||
BaseIcon,
|
||||
},
|
||||
mixins: [helpersMixin],
|
||||
data() {
|
||||
return {
|
||||
isFocused: false,
|
||||
query: '',
|
||||
searching: false,
|
||||
domainMode: false,
|
||||
domainNames: {},
|
||||
profilesUsed: new Set(),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
validSearch() {
|
||||
if (this.query === '') return true
|
||||
if (this.query.match(/^[a-f0-9A-F]{64}$/)) return true
|
||||
if (this.isBech32Key(this.query) && this.bech32ToHex(this.query).match(/^[a-f0-9A-F]{64}$/)) return true
|
||||
if (this.query.match(/^([a-z0-9A-Z-_.\u00C0-\u1FFF\u2800-\uFFFD]*@)?[a-z0-9A-Z-_]+[.]{1}[a-z0-9A-Z-_.]+$/)) return true
|
||||
return false
|
||||
},
|
||||
domainDefaultPubkey() {
|
||||
return this.domainNames._
|
||||
},
|
||||
domainUsers() {
|
||||
let users = Object.keys(this.domainNames).filter((name) => name !== '_').map((name) => { return { 'name': name, 'pubkey': this.domainNames[name] } })
|
||||
return users
|
||||
},
|
||||
domain() {
|
||||
let [name, domain] = this.query.split('@')
|
||||
return domain || name
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFocus() {
|
||||
this.isFocused = !this.isFocused
|
||||
},
|
||||
async searchProfile(e) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!this.validSearch) {
|
||||
Notify.create({
|
||||
message: 'Invalid format! Please enter full public key or NIP05 identifier',
|
||||
color: 'negative'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.searching = true
|
||||
this.query = this.query.trim().toLowerCase()
|
||||
|
||||
if (this.query.match(/^[a-f0-9]{64}$/)) {
|
||||
this.toProfile(this.query)
|
||||
this.query = ''
|
||||
this.searching = false
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isBech32Key(this.query) && this.bech32ToHex(this.query).match(/^[a-f0-9A-F]{64}$/)) {
|
||||
this.toProfile(this.bech32ToHex(this.query))
|
||||
this.query = ''
|
||||
this.searching = false
|
||||
return
|
||||
}
|
||||
|
||||
if (this.query.match(/^([a-z0-9-_.\u00C0-\u1FFF\u2800-\uFFFD]*@)?[a-z0-9-_.]+[.]{1}[a-z0-9-_.]+$/)) {
|
||||
// if (!this.query.match(/^[a-z0-9-_.\u00C0-\u1FFF\u2800-\uFFFD]?@/)) {
|
||||
if (this.query.match(/^@/) || !this.query.match(/@/)) {
|
||||
// this.query = '_' + this.query
|
||||
// else if (!this.query.match(/@/)) this.query = '_@' + this.query
|
||||
this.domainNames = await searchDomain(this.domain)
|
||||
// this.domainUsers
|
||||
if (this.domainUsers.length || this.domainDefaultPubkey) {
|
||||
if (this.domainDefaultPubkey) this.useProfile(this.domainDefaultPubkey)
|
||||
if (this.domainUsers.length) this.domainUsers.forEach((user) => this.useProfile(user.pubkey))
|
||||
this.searching = false
|
||||
this.domainMode = true
|
||||
return
|
||||
}
|
||||
}
|
||||
// }
|
||||
console.log('this.domainUsers', this.domainUsers)
|
||||
let pubkey = await queryName(this.query)
|
||||
console.log('queryName returned: ', pubkey)
|
||||
if (pubkey) {
|
||||
this.toProfile(pubkey)
|
||||
this.query = ''
|
||||
this.searching = false
|
||||
return
|
||||
}
|
||||
}
|
||||
this.searching = false
|
||||
Notify.create({
|
||||
message: 'No user found! Please enter full public key or NIP05 identifier and double check search string',
|
||||
color: 'negative'
|
||||
})
|
||||
},
|
||||
useProfile(pubkey) {
|
||||
if (this.profilesUsed.has(pubkey)) return
|
||||
|
||||
this.profilesUsed.add(pubkey)
|
||||
this.$store.dispatch('useProfile', {pubkey})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'assets/theme/colors.scss';
|
||||
|
||||
.searchbox {
|
||||
height: 50px;
|
||||
padding: 12px 1rem;
|
||||
border-radius: 999px;
|
||||
margin: 1rem 0;
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
|
||||
border: 1px solid rgba($color: $color-dark-gray, $alpha: 0);
|
||||
transition: border 150ms ease;
|
||||
&-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&-icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
margin-right: 1rem;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: #fff;
|
||||
transition: border 150ms ease;
|
||||
}
|
||||
}
|
||||
&-input {
|
||||
flex-grow: 1;
|
||||
input {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 100%;
|
||||
display: block;
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.focused {
|
||||
border: 1px solid rgba($color: $color-primary, $alpha: 1);
|
||||
svg {
|
||||
fill: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
62
src/components/Sidebar/WelcomeBox.vue
Normal file
62
src/components/Sidebar/WelcomeBox.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="welcome" v-if="!app.isSignedIn">
|
||||
<div class="welcome-header">
|
||||
<h3>New to Nostr?</h3>
|
||||
</div>
|
||||
<div class="welcome-content">
|
||||
<button class="btn btn-primary" @click="signUp">Create Account</button>
|
||||
<button class="btn" @click="signIn">Log in</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useAppStore} from 'stores/App'
|
||||
|
||||
export default {
|
||||
name: 'WelcomeBox',
|
||||
setup() {
|
||||
return {
|
||||
app: useAppStore(),
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
signUp() {
|
||||
this.app.signIn('sign-up')
|
||||
},
|
||||
signIn() {
|
||||
this.app.signIn('sign-in')
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
|
||||
.welcome {
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.1);
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
&-header {
|
||||
padding: 1rem;
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
&-content {
|
||||
border-top: $border-dark;
|
||||
padding: 1rem;
|
||||
button {
|
||||
width: 100%;
|
||||
padding: .5rem;
|
||||
&:first-child {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
161
src/components/SignIn/SignInDialog.vue
Normal file
161
src/components/SignIn/SignInDialog.vue
Normal file
@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<q-dialog v-model="app.signInDialog.open" @before-show="updateFragment" @hide="onClose">
|
||||
<div class="sign-in-dialog">
|
||||
<q-btn
|
||||
v-if="showClose"
|
||||
icon="close"
|
||||
size="md"
|
||||
class="icon"
|
||||
flat
|
||||
round
|
||||
v-close-popup
|
||||
/>
|
||||
<q-btn
|
||||
v-if="showBack"
|
||||
@click="fragment = 'welcome'"
|
||||
icon="arrow_back"
|
||||
size="md"
|
||||
class="icon"
|
||||
flat
|
||||
round
|
||||
/>
|
||||
|
||||
<div class="logo">
|
||||
<UserAvatar v-if="pubkey" :pubkey="pubkey" />
|
||||
<Logo v-else />
|
||||
</div>
|
||||
|
||||
<div v-if="fragment === 'welcome'" class="welcome">
|
||||
<p class="prompt">
|
||||
{{ prompt }}
|
||||
</p>
|
||||
<button class="btn btn-primary" @click.stop="fragment = 'sign-up'">Create Account</button>
|
||||
<button class="btn" @click.stop="fragment = 'sign-in'">Log in</button>
|
||||
</div>
|
||||
|
||||
<SignUpForm v-if="fragment === 'sign-up'" @complete="onComplete" />
|
||||
<SignInForm v-if="fragment === 'sign-in'" @complete="onComplete"/>
|
||||
|
||||
<div v-if="fragment === 'complete'" class="complete">
|
||||
<h3>Signed in as</h3>
|
||||
<p>
|
||||
<UserName v-if="pubkey" :pubkey="pubkey" two-line />
|
||||
</p>
|
||||
<button class="btn btn-primary" v-close-popup>Let's go</button>
|
||||
</div>
|
||||
</div>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Logo from 'components/Logo.vue'
|
||||
import UserAvatar from 'components/User/UserAvatar.vue'
|
||||
import UserName from 'components/User/UserName.vue'
|
||||
import SignUpForm from 'components/SignIn/SignUpForm.vue'
|
||||
import SignInForm from 'components/SignIn/SignInForm.vue'
|
||||
import {useAppStore} from 'stores/App'
|
||||
|
||||
export default {
|
||||
name: 'SignInDialog',
|
||||
components: {
|
||||
UserName,
|
||||
Logo,
|
||||
UserAvatar,
|
||||
SignInForm,
|
||||
SignUpForm
|
||||
},
|
||||
props: {
|
||||
prompt: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
app: useAppStore(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fragment: 'welcome',
|
||||
pubkey: null,
|
||||
backAllowed: true,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showClose() {
|
||||
return this.fragment === 'welcome'
|
||||
|| (this.fragment !== 'complete' && !this.backAllowed)
|
||||
},
|
||||
showBack() {
|
||||
return this.fragment !== 'complete' && !this.showClose
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
if (typeof this.app.signInDialog.callback === 'function') {
|
||||
this.app.signInDialog.callback.call(null, this.pubkey || false)
|
||||
}
|
||||
this.fragment = 'welcome'
|
||||
this.pubkey = null
|
||||
},
|
||||
onComplete({pubkey}) {
|
||||
this.pubkey = pubkey
|
||||
this.fragment = 'complete'
|
||||
},
|
||||
updateFragment() {
|
||||
this.fragment = this.app.signInDialog.fragment || 'welcome'
|
||||
this.backAllowed = this.fragment === 'welcome'
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
@import "assets/variables.scss";
|
||||
|
||||
.sign-in-dialog {
|
||||
position: relative;
|
||||
background-color: $color-bg;
|
||||
padding: 1rem;
|
||||
min-width: 440px;
|
||||
text-align: center;
|
||||
.icon {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: .5rem;
|
||||
left: .5rem;
|
||||
fill: #fff;
|
||||
}
|
||||
.logo {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
margin: auto;
|
||||
> * {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
}
|
||||
}
|
||||
.welcome {
|
||||
button {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
.complete {
|
||||
button {
|
||||
margin: 2rem auto auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $phone-lg) {
|
||||
.sign-in-dialog {
|
||||
min-width: unset;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
108
src/components/SignIn/SignInForm.vue
Normal file
108
src/components/SignIn/SignInForm.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="sign-in">
|
||||
<h3>Log in</h3>
|
||||
<q-form @submit.stop="signIn">
|
||||
<label for="private-key">Paste your private key</label>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="privateKey"
|
||||
id="private-key"
|
||||
placeholder="nsec..."
|
||||
maxlength="64"
|
||||
:class="{
|
||||
valid: validKey,
|
||||
invalid: invalidKey,
|
||||
}"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary" :disabled="!validKey">Log in</button>
|
||||
</q-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SignInForm',
|
||||
emits: ['complete'],
|
||||
data() {
|
||||
return {
|
||||
privateKey: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
validKey() {
|
||||
return this.isValidKey(this.privateKey)
|
||||
},
|
||||
invalidKey() {
|
||||
return this.privateKey
|
||||
&& this.privateKey.length >= 63
|
||||
&& !this.isValidKey(this.privateKey)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isValidKey(str) {
|
||||
// FIXME
|
||||
// return this.isBech32Key(str)
|
||||
return false
|
||||
},
|
||||
signIn() {
|
||||
if (!this.validKey) return
|
||||
|
||||
// FIXME
|
||||
// const priv = this.bech32ToHex(this.privateKey)
|
||||
// const pub = getPublicKey(priv)
|
||||
//
|
||||
// const keys = {priv, pub}
|
||||
// this.$store.dispatch('initKeys', keys)
|
||||
// this.$store.dispatch('launch')
|
||||
//
|
||||
// const account = {secret: priv}
|
||||
// this.$store.commit('addOrUpdateAccount', {
|
||||
// pubkey: pub,
|
||||
// account
|
||||
// })
|
||||
//
|
||||
// this.$emit('complete', {pubkey: pub})
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.input.focus()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
|
||||
.sign-in {
|
||||
margin: auto;
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
input {
|
||||
color: #fff;
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
border-radius: 999px;
|
||||
margin: 1rem auto;
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
|
||||
border: 1px solid rgba($color: $color-dark-gray, $alpha: 0);
|
||||
transition: border 150ms ease;
|
||||
&:focus {
|
||||
border: 1px solid rgba($color: $color-primary, $alpha: 1);
|
||||
outline: none;
|
||||
}
|
||||
&.valid {
|
||||
border-color: #44a644;
|
||||
color: #44a644;
|
||||
}
|
||||
&.invalid {
|
||||
border-color: #d41b1b;
|
||||
color: #d41b1b;
|
||||
}
|
||||
}
|
||||
button {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
82
src/components/SignIn/SignUpForm.vue
Normal file
82
src/components/SignIn/SignUpForm.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="sign-up">
|
||||
<h3>Create Account</h3>
|
||||
<q-form @submit.stop="signUp">
|
||||
<label for="username">What's your name?</label>
|
||||
<input v-model="username" ref="input" id="username" autocomplete="false" />
|
||||
<button type="submit" class="btn btn-primary" :disabled="!validUsername">Create</button>
|
||||
</q-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SignUpForm',
|
||||
emits: ['complete'],
|
||||
data() {
|
||||
return {
|
||||
username: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
validUsername() {
|
||||
return this.username && this.username.length > 0
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
signUp() {
|
||||
if (!this.validUsername) return
|
||||
|
||||
// FIXME
|
||||
// const priv = generatePrivateKey()
|
||||
// const pub = getPublicKey(priv)
|
||||
// const keys = {pub, priv}
|
||||
//
|
||||
// this.$store.dispatch('initKeys', keys)
|
||||
// this.$store.dispatch('launch')
|
||||
//
|
||||
// const account = {secret: priv}
|
||||
// this.$store.commit('addOrUpdateAccount', {
|
||||
// pubkey: pub,
|
||||
// account
|
||||
// })
|
||||
//
|
||||
// this.$store.dispatch('setMetadata', {name: this.username})
|
||||
//
|
||||
// this.$emit('complete', {pubkey: pub, name: this.username})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.input.focus()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
|
||||
.sign-up {
|
||||
margin: auto;
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
input {
|
||||
color: #fff;
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
border-radius: 999px;
|
||||
margin: 1rem auto;
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.3);
|
||||
border: 1px solid rgba($color: $color-dark-gray, $alpha: 0);
|
||||
transition: border 150ms ease;
|
||||
&:focus {
|
||||
border: 1px solid rgba($color: $color-primary, $alpha: 1);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
button {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
43
src/components/Trends/Item.vue
Normal file
43
src/components/Trends/Item.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="trends-item">
|
||||
<h3>{{ data.name }}</h3>
|
||||
<span>{{ nicePostCount }} posts</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TrendsItem',
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
nicePostCount() {
|
||||
const stringNumber = this.data.postCount.toString()
|
||||
if (stringNumber.length > 4) {
|
||||
return stringNumber.substring(0, stringNumber.length - 3) + 'K'
|
||||
}
|
||||
return stringNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'assets/theme/colors.scss';
|
||||
|
||||
.trends-item {
|
||||
padding: 1rem;
|
||||
border-top: $border-dark;
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
}
|
||||
span {
|
||||
color: $color-dark-gray;
|
||||
}
|
||||
}
|
||||
</style>
|
68
src/components/Trends/index.vue
Normal file
68
src/components/Trends/index.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="trends">
|
||||
<div class="trends-wrapper">
|
||||
<div class="trends-header">
|
||||
<h3>Trends</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="trends"
|
||||
class="trends-body"
|
||||
>
|
||||
<TrendsItem
|
||||
v-for="(trend, i) in sortedTrends"
|
||||
:key="i"
|
||||
:data="trend"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TrendsItem from 'components/Trends/Item'
|
||||
|
||||
export default {
|
||||
name: 'Trends',
|
||||
components: {
|
||||
TrendsItem,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
trends: [
|
||||
{
|
||||
name: '#todo',
|
||||
postCount: 58
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedTrends() {
|
||||
const trendsArray = this.trends
|
||||
trendsArray.sort((a, b) => a.postCount > b.postCount ? -1 : 1)
|
||||
return trendsArray
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "assets/theme/colors.scss";
|
||||
|
||||
.trends {
|
||||
background-color: rgba($color: $color-dark-gray, $alpha: 0.1);
|
||||
border-radius: 1rem;
|
||||
&-wrapper {
|
||||
}
|
||||
&-header {
|
||||
padding: 1rem;
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
&-body {
|
||||
}
|
||||
}
|
||||
</style>
|
25
src/components/User/Identicon.vue
Normal file
25
src/components/User/Identicon.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<svg></svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {updateSvg} from 'jdenticon'
|
||||
|
||||
export default {
|
||||
name: 'Identicon',
|
||||
props: {
|
||||
pubkey: {
|
||||
type: String,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
pubkey(pubkey) {
|
||||
updateSvg(this.$el, pubkey)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
updateSvg(this.$el, this.pubkey)
|
||||
}
|
||||
}
|
||||
</script>
|
99
src/components/User/Nip05Badge.vue
Normal file
99
src/components/User/Nip05Badge.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<q-dialog v-model="NIP05Dialog">
|
||||
<q-card class="flex column no-wrap" style="max-height: 90%">
|
||||
<div class="flex row justify-end">
|
||||
<q-btn icon="close" flat dense v-close-popup/>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 flex row overflow-auto items-end q-gutter-sm">
|
||||
NIP05 identifier
|
||||
<a :href="NIP05Link" target="_">{{ NIP05Link }}</a>
|
||||
</div>
|
||||
<pre v-if="NIP05Loaded">{{ NIP05Formatted }}</pre>
|
||||
<q-inner-loading :showing="!NIP05Loaded">
|
||||
<q-spinner-orbit color="accent" size="2rem" />
|
||||
</q-inner-loading>
|
||||
<div>
|
||||
Learn how to get NIP05 verified <a href="https://gist.github.com/metasikander/609a538e6a03b2f67e5c8de625baed3e" target='_'>here</a>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-btn
|
||||
v-if="$store.getters.NIP05Id(pubkey)"
|
||||
icon="verified"
|
||||
flat
|
||||
dense
|
||||
:size="size"
|
||||
class="no-padding"
|
||||
clickable
|
||||
@click.stop="openNIP05"
|
||||
>
|
||||
<q-tooltip>
|
||||
NIP05 verified
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import fetch from 'cross-fetch'
|
||||
|
||||
export default {
|
||||
name: 'Nip05Badge',
|
||||
props: {
|
||||
pubkey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'xs'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
NIP05Dialog: false,
|
||||
NIP05Data: {},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
NIP05Link() {
|
||||
// let [name, domain] = this.$store.getters
|
||||
// .NIP05Id(this.pubkey)
|
||||
// .split('@')
|
||||
// if (!domain) {
|
||||
// domain = name
|
||||
// name = '_'
|
||||
// }
|
||||
// return `https://${domain}/.well-known/nostr.json?name=${name}`
|
||||
return 'TODO'
|
||||
},
|
||||
NIP05Formatted() {
|
||||
return this.json(this.NIP05Data)
|
||||
},
|
||||
NIP05Loaded() {
|
||||
if (Object.keys(this.NIP05Data).length) return true
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
openNIP05() {
|
||||
this.loadNIP05Data()
|
||||
this.NIP05Dialog = !this.NIP05Dialog
|
||||
},
|
||||
|
||||
async loadNIP05Data() {
|
||||
try {
|
||||
this.NIP05Data = await (await fetch(this.NIP05Link)).json()
|
||||
} catch (e) {
|
||||
console.warn(`Failed to fetch NIP05 identifier ${this.NIP05Link}`, e)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
66
src/components/User/UserAvatar.vue
Normal file
66
src/components/User/UserAvatar.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<q-avatar
|
||||
@click="clickable && linkToProfile(pubkey)"
|
||||
class="relative-position"
|
||||
:class="{'cursor-pointer': clickable}"
|
||||
>
|
||||
<img
|
||||
v-if="hasAvatar && !avatarFetchFailed"
|
||||
:src="avatarUrl"
|
||||
ref="image"
|
||||
loading="lazy"
|
||||
crossorigin
|
||||
@error.once="onFetchFailed"
|
||||
/>
|
||||
<Identicon
|
||||
v-if="!hasAvatar || avatarFetchFailed"
|
||||
:pubkey="pubkey"
|
||||
/>
|
||||
</q-avatar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Identicon from 'components/User/Identicon.vue'
|
||||
import routerMixin from 'src/router/mixin'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
|
||||
export default {
|
||||
mixins: [routerMixin],
|
||||
components: {
|
||||
Identicon,
|
||||
},
|
||||
props: {
|
||||
pubkey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
avatarFetchFailed: false,
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
nostr: useNostrStore()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasAvatar() {
|
||||
return !!this.avatarUrl
|
||||
},
|
||||
avatarUrl() {
|
||||
return this.nostr.getProfile(this.pubkey)?.picture
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onFetchFailed() {
|
||||
this.avatarFetchFailed = true
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
120
src/components/User/UserName.vue
Normal file
120
src/components/User/UserName.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<span
|
||||
class="username"
|
||||
:class="{'two-line': twoLine, clickable}"
|
||||
>
|
||||
<a @click="clickable && linkToProfile(pubkey)">
|
||||
<span
|
||||
v-if="profile?.name"
|
||||
class="name"
|
||||
>
|
||||
{{ profile.name }}
|
||||
<q-icon v-if="showFollowing && isFollow" name="visibility" color="secondary">
|
||||
<q-tooltip>
|
||||
following
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</span>
|
||||
<Bech32Label v-if="twoLine || !profile?.name" prefix="npub" :hex="pubkey" class="pubkey" />
|
||||
</a>
|
||||
|
||||
<span v-if="showVerified && profile?.nip05?.verified">
|
||||
<Nip05Badge :pubkey="pubkey" />
|
||||
<span style="opacity: .9; font-size: 90%; font-weight: 300; line-height: 90%">
|
||||
{{ niceNip05 }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Nip05Badge from 'components/User/Nip05Badge.vue'
|
||||
import Bech32Label from 'components/Bech32Label.vue'
|
||||
import routerMixin from 'src/router/mixin'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
|
||||
export default {
|
||||
mixins: [routerMixin],
|
||||
components: {
|
||||
Bech32Label,
|
||||
Nip05Badge,
|
||||
},
|
||||
props: {
|
||||
pubkey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
twoLine: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showFollowing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showVerified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
nostr: useNostrStore()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
profile() {
|
||||
return this.nostr.getProfile(this.pubkey)
|
||||
},
|
||||
niceNip05() {
|
||||
return this.profile.nip05.url
|
||||
.split('@')
|
||||
.filter(part => part !== '_' && part !== this.profile.name)
|
||||
.join('@')
|
||||
},
|
||||
isFollow() {
|
||||
// FIXME
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
|
||||
.username {
|
||||
cursor: pointer;
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
> span + span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
&.two-line {
|
||||
display: block;
|
||||
> a > span {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
}
|
||||
.pubkey:not(:first-child) {
|
||||
color: $color-dark-gray;
|
||||
.pubkey-value {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.clickable {
|
||||
.name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.pubkey:first-child:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
58
src/css/app.scss
Normal file
58
src/css/app.scss
Normal file
@ -0,0 +1,58 @@
|
||||
@import "src/assets/theme/colors.scss";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
&::before, &::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
html, body, :root {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: $color-bg;
|
||||
color: $color-fg;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
line-height: unset;
|
||||
letter-spacing: unset;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
line-height: unset;
|
||||
letter-spacing: unset;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 1rem 2rem;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
border: 1px solid $color-primary;
|
||||
border-radius: 999px;
|
||||
transition: 200ms ease;
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-primary, $alpha: 0.2);
|
||||
border-color: rgba($color: $color-primary, $alpha: 0.7);
|
||||
}
|
||||
|
||||
&-primary {
|
||||
background-color: $color-primary;
|
||||
&:hover {
|
||||
background-color: rgba($color: $color-primary, $alpha: 0.7);
|
||||
border-color: rgba($color: $color-primary, $alpha: 0.3);
|
||||
}
|
||||
}
|
||||
}
|
25
src/css/quasar.variables.scss
Normal file
25
src/css/quasar.variables.scss
Normal file
@ -0,0 +1,25 @@
|
||||
// Quasar SCSS (& Sass) Variables
|
||||
// --------------------------------------------------
|
||||
// To customize the look and feel of this app, you can override
|
||||
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
|
||||
|
||||
// Check documentation for full list of Quasar variables
|
||||
|
||||
// Your own variables (that are declared here) and Quasar's own
|
||||
// ones will be available out of the box in your .vue/.scss/.sass files
|
||||
|
||||
// It's highly recommended to change the default colors
|
||||
// to match your app's branding.
|
||||
// Tip: Use the "Theme Builder" on Quasar's documentation website.
|
||||
|
||||
$primary : #1976D2;
|
||||
$secondary : #26A69A;
|
||||
$accent : #9C27B0;
|
||||
|
||||
$dark : #1D1D1D;
|
||||
$dark-page : #121212;
|
||||
|
||||
$positive : #21BA45;
|
||||
$negative : #C10015;
|
||||
$info : #31CCEC;
|
||||
$warning : #F2C037;
|
7
src/i18n/en-US/index.js
Normal file
7
src/i18n/en-US/index.js
Normal file
@ -0,0 +1,7 @@
|
||||
// This is just an example,
|
||||
// so you can safely delete all default props below
|
||||
|
||||
export default {
|
||||
failed: 'Action failed',
|
||||
success: 'Action was successful'
|
||||
}
|
5
src/i18n/index.js
Normal file
5
src/i18n/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
import enUS from './en-US'
|
||||
|
||||
export default {
|
||||
'en-US': enUS
|
||||
}
|
22
src/index.template.html
Normal file
22
src/index.template.html
Normal file
@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title><%= productName %></title>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="<%= productDescription %>">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="msapplication-tap-highlight" content="no">
|
||||
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
|
||||
|
||||
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
|
||||
<link rel="icon" type="image/ico" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<!-- DO NOT touch the following DIV -->
|
||||
<div id="q-app"></div>
|
||||
</body>
|
||||
</html>
|
199
src/layouts/MainLayout.vue
Normal file
199
src/layouts/MainLayout.vue
Normal file
@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<q-layout>
|
||||
<div class="layout" @click="mobileMenuOpen = false">
|
||||
<div class="layout-menu">
|
||||
<div class="layout-menu-fixed" :class="{active: mobileMenuOpen}">
|
||||
<MainMenu @click.stop @mobile-menu-close="mobileMenuOpen = false" hide-items-requiring-sign-in />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout-flow">
|
||||
<q-page-container ref="pageContainer">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="cachedPages">
|
||||
<component :is="Component" :key="$route.path" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</q-page-container>
|
||||
</div>
|
||||
|
||||
<div class="layout-sidebar">
|
||||
<div class="layout-sidebar-fixed">
|
||||
<!-- <SearchBox />-->
|
||||
<WelcomeBox />
|
||||
<Trends />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mobile-menu-toggler"
|
||||
@click.stop="mobileMenuOpen = !mobileMenuOpen"
|
||||
>
|
||||
<BaseIcon icon="hamburger" />
|
||||
</div>
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu-backdrop fixed-full" v-close-popup></div>
|
||||
</div>
|
||||
|
||||
<SignInDialog />
|
||||
<CreatePostDialog />
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {defineComponent} from 'vue'
|
||||
import {useQuasar} from 'quasar'
|
||||
import MainMenu from 'components/MainMenu/MainMenu.vue'
|
||||
// import SearchBox from 'components/SearchBox/SearchBox.vue'
|
||||
import WelcomeBox from 'components/Sidebar/WelcomeBox.vue'
|
||||
import Trends from 'components/Trends/index.vue'
|
||||
import BaseIcon from 'components/BaseIcon/index.vue'
|
||||
import SignInDialog from 'components/SignIn/SignInDialog.vue'
|
||||
import CreatePostDialog from 'components/CreatePost/CreatePostDialog.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MainLayout',
|
||||
components: {
|
||||
MainMenu,
|
||||
// SearchBox,
|
||||
WelcomeBox,
|
||||
Trends,
|
||||
BaseIcon,
|
||||
SignInDialog,
|
||||
CreatePostDialog,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
cachedPages: ['Feed', 'Notifications', 'Messages', 'Inbox', 'Settings'],
|
||||
mobileMenuOpen: false,
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const $q = useQuasar()
|
||||
$q.screen.setSizes({
|
||||
// FIXME Needs to be in sync with assets/variables.scss
|
||||
sm: 414,
|
||||
md: 755,
|
||||
lg: 1113,
|
||||
xl: 1310,
|
||||
})
|
||||
return $q
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/theme/colors.scss";
|
||||
@import "assets/variables.scss";
|
||||
|
||||
.layout {
|
||||
min-height: 100%;
|
||||
max-width: 1290px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
&-menu {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
&-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
max-width: inherit;
|
||||
z-index: 1000;
|
||||
}
|
||||
}
|
||||
&-flow {
|
||||
border-right: $border-dark;
|
||||
border-left: $border-dark;
|
||||
width: 100%;
|
||||
min-width: 660px;
|
||||
max-width: 660px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
&-sidebar {
|
||||
width: 100%;
|
||||
min-width: 330px;
|
||||
margin: 0 1rem;
|
||||
&-fixed {
|
||||
position: fixed;
|
||||
width: 330px;
|
||||
max-width: inherit;
|
||||
}
|
||||
}
|
||||
.mobile-menu-toggler {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet-sm) {
|
||||
.layout-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $phone-lg) {
|
||||
.layout-menu {
|
||||
max-width: 80px;
|
||||
}
|
||||
.layout-flow {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $phone) {
|
||||
.layout {
|
||||
&-menu {
|
||||
max-width: 300px;
|
||||
width: unset;
|
||||
&-fixed {
|
||||
position: fixed;
|
||||
background-color: $color-bg;
|
||||
transform: translateX(-100%);
|
||||
transition: 200ms ease;
|
||||
z-index: 1000;
|
||||
&.active {
|
||||
transform: translateX(0%);
|
||||
box-shadow: $shadow-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
&-flow {
|
||||
border: 0;
|
||||
}
|
||||
&-sidebar {
|
||||
display: none;
|
||||
max-width: unset;
|
||||
&-fixed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.mobile-menu-toggler {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
padding: 6px 8px 8px;
|
||||
border-radius: 999px;
|
||||
background-color: $color-primary;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 555;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
.mobile-menu-backdrop {
|
||||
z-index: 750;
|
||||
pointer-events: all;
|
||||
outline: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
49
src/layouts/TestLayout.vue
Normal file
49
src/layouts/TestLayout.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<q-layout>
|
||||
Test
|
||||
<div>Pubkey: {{ profile?.name }}</div>
|
||||
<div>Pubkey: {{ profile2?.name }}</div>
|
||||
<div>SignedIn: {{ app.isSignedIn }}</div>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {defineComponent} from 'vue'
|
||||
import {useNostrStore} from 'src/nostr/NostrStore'
|
||||
import {useAppStore} from 'stores/App'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TestLayout',
|
||||
props: {
|
||||
pubkey: {
|
||||
type: String,
|
||||
default: '6f32dddf2d54f2c5e64e1570abcb9c7a05e8041bac0ee9f4235f694fccb68b5d',
|
||||
},
|
||||
pubkey2: {
|
||||
type: String,
|
||||
default: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93',
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
profile() {
|
||||
console.log('profile')
|
||||
return this.nostr.getProfile(this.pubkey)
|
||||
},
|
||||
profile2() {
|
||||
console.log('profile2')
|
||||
return this.nostr.getProfile(this.pubkey2)
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
nostr: useNostrStore(),
|
||||
app: useAppStore(),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
87
src/nostr/FetchQueue.js
Normal file
87
src/nostr/FetchQueue.js
Normal file
@ -0,0 +1,87 @@
|
||||
import {Observable} from 'src/nostr/utils'
|
||||
|
||||
export default class FetchQueue extends Observable {
|
||||
constructor(client, subId, fnGetId, fnCreateFilter, opts = {}) {
|
||||
super()
|
||||
this.client = client
|
||||
this.subId = subId
|
||||
this.fnGetId = fnGetId
|
||||
this.fnCreateFilter = fnCreateFilter
|
||||
this.throttle = opts.throttle || 250
|
||||
this.batchSize = opts.batchSize || 20
|
||||
this.retryDelay = opts.retryDelay || 3000
|
||||
this.maxRetries = opts.maxRetries || 3
|
||||
|
||||
this.queue = {}
|
||||
this.fetching = false
|
||||
this.fetchQueued = false
|
||||
this.retryInterval = null
|
||||
}
|
||||
|
||||
add(id) {
|
||||
if (this.queue[id] === undefined) return
|
||||
this.queue[id] = 0
|
||||
|
||||
if (!this.fetching) {
|
||||
this.fetch()
|
||||
} else if (!this.fetchQueued) {
|
||||
setTimeout(this.fetch.bind(this), this.throttle)
|
||||
this.fetchQueued = true
|
||||
}
|
||||
}
|
||||
|
||||
fetch() {
|
||||
this.fetchQueued = false
|
||||
if (this.retryInterval) clearInterval(this.retryInterval)
|
||||
|
||||
const ids = Object.keys(this.queue).slice(0, this.batchSize)
|
||||
if (!ids.length) return
|
||||
|
||||
console.log(`Fetching ${ids.length} ${this.subId}s`, ids)
|
||||
|
||||
// Remove ids that we have tried too many times.
|
||||
const filteredIds = []
|
||||
for (const id of ids) {
|
||||
this.queue[id]++
|
||||
if (this.queue[id] > this.maxRetries) {
|
||||
console.warn(`Failed to fetch ${this.subId} ${id}`)
|
||||
delete this.queue[id]
|
||||
} else {
|
||||
filteredIds.push(id)
|
||||
}
|
||||
}
|
||||
if (!filteredIds.length) return
|
||||
|
||||
this.fetching = true
|
||||
this.retryInterval = setInterval(this.fetch.bind(this), this.retryDelay)
|
||||
|
||||
// XXX Needed for some relays?
|
||||
this.client.unsubscribe(this.subId)
|
||||
|
||||
this.client.subscribe(
|
||||
this.fnCreateFilter(ids),
|
||||
(event, relay, subId) => {
|
||||
const id = this.fnGetId(event)
|
||||
delete this.queue[id]
|
||||
ids.splice(ids.indexOf(id), 1)
|
||||
|
||||
console.log(`Fetched ${this.subId} ${id}, ${ids.length} remaining`)
|
||||
|
||||
this.emit('event', event, relay)
|
||||
|
||||
if (Object.keys(this.queue).length === 0) {
|
||||
console.log(`Fetched all ${this.subId}s`)
|
||||
if (this.retryInterval) clearInterval(this.retryInterval)
|
||||
this.client.unsubscribe(subId)
|
||||
this.fetching = false
|
||||
} else if (ids.length === 0) {
|
||||
console.log(`Batch ${this.subId} fetched, requesting more (${Object.keys(this.queue).length} remain)`)
|
||||
this.fetch()
|
||||
}
|
||||
},
|
||||
{
|
||||
subId: this.subId,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
103
src/nostr/NostrClient.js
Normal file
103
src/nostr/NostrClient.js
Normal file
@ -0,0 +1,103 @@
|
||||
import RelayPool from 'src/nostr/RelayPool'
|
||||
import Event from 'src/nostr/model/Event'
|
||||
|
||||
export const CancelAfter = {
|
||||
SINGLE: 'single',
|
||||
EOSE: 'eose',
|
||||
NEVER: 'never',
|
||||
}
|
||||
|
||||
export default class NostrClient {
|
||||
constructor(relays) {
|
||||
this.pool = new RelayPool(relays)
|
||||
this.pool.on('event', this.onEvent.bind(this))
|
||||
this.pool.on('eose', this.onEose.bind(this))
|
||||
this.pool.on('notice', this.onNotice.bind(this))
|
||||
this.pool.on('ok', this.onOk.bind(this))
|
||||
|
||||
this.subs = {}
|
||||
this.nextSubId = 0
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.pool.connect()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.pool.disconnect()
|
||||
}
|
||||
|
||||
subscribe(filters, callback, opts) {
|
||||
let subId
|
||||
if (opts?.subId) {
|
||||
//if (this.subs[opts.subId]) throw new Error(`SubId '${opts.subId}' already exists`)
|
||||
subId = opts.subId
|
||||
} else {
|
||||
subId = `sub${this.nextSubId++}`
|
||||
}
|
||||
|
||||
this.subs[subId] = {
|
||||
eventCallback: callback,
|
||||
eoseCallback: opts.eoseCallback,
|
||||
cancelAfter: opts.cancelAfter || CancelAfter.NEVER,
|
||||
}
|
||||
this.pool.subscribe(subId, filters)
|
||||
|
||||
return subId
|
||||
}
|
||||
|
||||
unsubscribe(subId) {
|
||||
this.pool.unsubscribe(subId)
|
||||
delete this.subs[subId]
|
||||
}
|
||||
|
||||
send(event) {
|
||||
return this.pool.send(event)
|
||||
}
|
||||
|
||||
onEvent(relay, subId, ev) {
|
||||
const event = Event.from(ev)
|
||||
if (!event.validate()) {
|
||||
// TODO Close relay?
|
||||
console.error(`Invalid event from ${relay}`, event)
|
||||
return
|
||||
}
|
||||
|
||||
const sub = this.subs[subId]
|
||||
if (!sub) {
|
||||
console.warn(`Event for invalid subId ${subId} from ${relay}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof sub.eventCallback === 'function') {
|
||||
sub.eventCallback(event, relay, subId)
|
||||
}
|
||||
|
||||
if (sub.cancelAfter === CancelAfter.SINGLE) {
|
||||
this.unsubscribe(subId)
|
||||
}
|
||||
}
|
||||
|
||||
onEose(relay, subId) {
|
||||
console.log(`[EOSE] from ${relay} for ${subId}`)
|
||||
|
||||
const sub = this.subs[subId]
|
||||
if (!sub) return
|
||||
|
||||
if (typeof sub.eoseCallback === 'function') {
|
||||
sub.eoseCallback(relay, subId)
|
||||
}
|
||||
|
||||
if (sub.cancelAfter === CancelAfter.EOSE) {
|
||||
this.unsubscribe(subId)
|
||||
}
|
||||
}
|
||||
|
||||
onNotice(relay, message) {
|
||||
console.warn(`[NOTICE] from ${relay}: ${message}`)
|
||||
}
|
||||
|
||||
onOk(relay, eventId, wasSaved, message) {
|
||||
console.log(`[OK] from ${relay}: eventId=${eventId}, wasSaved=${wasSaved}, message=${message}`)
|
||||
}
|
||||
}
|
229
src/nostr/NostrStore.js
Normal file
229
src/nostr/NostrStore.js
Normal file
@ -0,0 +1,229 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {EventKind} from 'src/nostr/model/Event'
|
||||
import NostrClient from 'src/nostr/NostrClient'
|
||||
import {NoteOrder, useNoteStore} from 'src/nostr/store/NoteStore'
|
||||
import {useProfileStore} from 'src/nostr/store/ProfileStore'
|
||||
import {markRaw} from 'vue'
|
||||
import FetchQueue from 'src/nostr/FetchQueue'
|
||||
|
||||
// TODO Move to settings
|
||||
const RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nostr-relay.wlvs.space',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://nostr.oxtr.dev',
|
||||
]
|
||||
|
||||
export const Feeds = {
|
||||
GLOBAL: {
|
||||
name: 'global',
|
||||
filters: {
|
||||
kinds: [EventKind.NOTE, EventKind.DELETE],
|
||||
},
|
||||
initialFetchSize: 100,
|
||||
},
|
||||
}
|
||||
|
||||
const eventQueue = (client, subId) => new FetchQueue(
|
||||
client,
|
||||
subId,
|
||||
event => event.id,
|
||||
ids => ({
|
||||
ids: ids
|
||||
})
|
||||
)
|
||||
|
||||
const profileQueue = client => new FetchQueue(
|
||||
client,
|
||||
'profile',
|
||||
event => event.pubkey,
|
||||
pubkeys => ({
|
||||
kinds: [EventKind.METADATA],
|
||||
authors: pubkeys
|
||||
})
|
||||
)
|
||||
|
||||
export const useNostrStore = defineStore('nostr', {
|
||||
state: () => ({
|
||||
// TODO Limit size. Remove oldest.
|
||||
seenBy: {}, // EventId -> {RelayURL -> Timestamp, ...}
|
||||
}),
|
||||
actions: {
|
||||
init() {
|
||||
this.client = markRaw(new NostrClient(RELAYS))
|
||||
this.client.connect()
|
||||
|
||||
this.profileQueue = profileQueue(this.client)
|
||||
this.profileQueue.on('event', this.addEvent.bind(this))
|
||||
|
||||
this.noteQueue = eventQueue(this.client, 'note')
|
||||
this.noteQueue.on('event', this.addEvent.bind(this))
|
||||
},
|
||||
addEvent(event, relay) {
|
||||
// console.log(`[EVENT] from ${relay}`, event)
|
||||
|
||||
if (this.seenBy[event.id]) {
|
||||
this.seenBy[event.id][relay.url] = Date.now()
|
||||
} else {
|
||||
this.seenBy[event.id] = {
|
||||
[relay.url]: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
switch (event.kind) {
|
||||
case EventKind.METADATA: {
|
||||
const profiles = useProfileStore()
|
||||
return profiles.addEvent(event)
|
||||
}
|
||||
case EventKind.NOTE: {
|
||||
const notes = useNoteStore()
|
||||
return notes.addEvent(event)
|
||||
}
|
||||
case EventKind.RELAY:
|
||||
break
|
||||
case EventKind.CONTACT:
|
||||
break
|
||||
case EventKind.DM:
|
||||
break
|
||||
case EventKind.DELETE:
|
||||
break
|
||||
case EventKind.SHARE:
|
||||
break
|
||||
case EventKind.REACTION:
|
||||
break
|
||||
case EventKind.CHATROOM:
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
hasEvent(id) {
|
||||
return !!this.seenBy[id]
|
||||
},
|
||||
|
||||
getProfile(pubkey) {
|
||||
const profiles = useProfileStore()
|
||||
const profile = profiles.get(pubkey)
|
||||
if (!profile) this.profileQueue.add(pubkey)
|
||||
return profile
|
||||
},
|
||||
|
||||
getNote(id) {
|
||||
const notes = useNoteStore()
|
||||
const note = notes.get(id)
|
||||
if (!note) this.noteQueue.add(id)
|
||||
return note
|
||||
},
|
||||
|
||||
getNotesByAuthor(pubkey, opts = {}) {
|
||||
const order = opts.order || NoteOrder.CREATION_DATE_DESC
|
||||
const notes = useNoteStore()
|
||||
return notes.allByAuthor(pubkey, order)
|
||||
},
|
||||
|
||||
fetchNotesByAuthor(pubkey, opts = {}) {
|
||||
const limit = opts.limit || 100
|
||||
return this.fetchMultiple(
|
||||
{
|
||||
kinds: [EventKind.NOTE],
|
||||
authors: [pubkey],
|
||||
},
|
||||
limit
|
||||
)
|
||||
},
|
||||
|
||||
streamFeed(feed, eventCallback, initialFetchCompleteCallback) {
|
||||
return this.streamEvents(
|
||||
feed.filters,
|
||||
feed.initialFetchSize,
|
||||
eventCallback,
|
||||
initialFetchCompleteCallback,
|
||||
{
|
||||
subId: feed.name,
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
cancelFeed(feed) {
|
||||
this.client.unsubscribe(feed.name)
|
||||
},
|
||||
|
||||
fetchEvent(id) {
|
||||
this.fetchSingle({
|
||||
ids: [id],
|
||||
})
|
||||
},
|
||||
|
||||
fetchSingle(filters) {
|
||||
const filtersWithLimit = Object.assign({}, filters, {limit: 1})
|
||||
return new Promise(resolve => {
|
||||
this.client.subscribe(
|
||||
filtersWithLimit,
|
||||
(event, relay) => {
|
||||
resolve(this.addEvent(event, relay))
|
||||
},
|
||||
{
|
||||
cancelAfter: 'single'
|
||||
}
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
fetchMultiple(filters, limit = 100) {
|
||||
const filtersWithLimit = Object.assign({}, filters, {limit})
|
||||
return new Promise(resolve => {
|
||||
const objects = []
|
||||
this.client.subscribe(
|
||||
filtersWithLimit,
|
||||
(event, relay, subId) => {
|
||||
const obj = this.addEvent(event, relay)
|
||||
if (!obj) return
|
||||
// TODO Deduplicate
|
||||
objects.push(obj)
|
||||
if (objects.length >= limit) {
|
||||
this.client.unsubscribe(subId)
|
||||
resolve(objects)
|
||||
}
|
||||
},
|
||||
{
|
||||
eoseCallback: (_relay, subId) => {
|
||||
this.client.unsubscribe(subId)
|
||||
resolve(objects)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
streamEvents(filters, initialFetchSize, eventCallback, initialFetchCompleteCallback, opts) {
|
||||
const filtersWithLimit = Object.assign({}, filters, {limit: initialFetchSize})
|
||||
|
||||
let numEventsSeen = 0
|
||||
let initialFetchComplete = false
|
||||
|
||||
return this.client.subscribe(
|
||||
filtersWithLimit,
|
||||
(event, relay) => {
|
||||
const obj = this.addEvent(event, relay)
|
||||
if (!obj) return
|
||||
|
||||
if (eventCallback) eventCallback(obj, relay)
|
||||
|
||||
if (++numEventsSeen >= initialFetchSize && !initialFetchComplete) {
|
||||
initialFetchComplete = true
|
||||
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
|
||||
}
|
||||
},
|
||||
{
|
||||
subId: opts.subId || null,
|
||||
cancelAfter: 'neven',
|
||||
eoseCallback: () => {
|
||||
if (!initialFetchComplete) {
|
||||
initialFetchComplete = true
|
||||
if (initialFetchCompleteCallback) initialFetchCompleteCallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
181
src/nostr/Relay.js
Normal file
181
src/nostr/Relay.js
Normal file
@ -0,0 +1,181 @@
|
||||
import {Observable} from 'src/nostr/utils'
|
||||
|
||||
export class Relay extends Observable {
|
||||
constructor(url, opts) {
|
||||
super()
|
||||
this.url = url
|
||||
|
||||
this.socket = new ReconnectingWebSocket(url, opts)
|
||||
this.socket.on('open', this.emit.bind(this, 'open', this))
|
||||
this.socket.on('close', this.emit.bind(this, 'close', this))
|
||||
this.socket.on('error', this.emit.bind(this, 'error', this))
|
||||
this.socket.on('message', this.onMessage.bind(this))
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.socket.connect()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.socket.disconnect()
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.socket.isConnected()
|
||||
}
|
||||
|
||||
send(event) {
|
||||
this.socket.send(['EVENT', event])
|
||||
}
|
||||
|
||||
subscribe(subId, filters) {
|
||||
this.socket.send(['REQ', subId, filters])
|
||||
}
|
||||
|
||||
unsubscribe(subId) {
|
||||
this.socket.send(['CLOSE', subId])
|
||||
}
|
||||
|
||||
onMessage(event) {
|
||||
try {
|
||||
this.processMessage(event.data)
|
||||
} catch (e) {
|
||||
// TODO Remove this relay?
|
||||
console.error(`Invalid message from ${this.url}: ${e.message || e}`, event.data, e)
|
||||
}
|
||||
}
|
||||
|
||||
processMessage(msg) {
|
||||
const array = JSON.parse(msg)
|
||||
|
||||
if (!Array.isArray(array) || array.length === 0) {
|
||||
throw new Error('not a nostr message')
|
||||
}
|
||||
|
||||
const type = array[0]
|
||||
switch (type) {
|
||||
case 'EVENT': {
|
||||
Relay.enforceArrayLength(array, 3)
|
||||
const [_, subId, event] = array
|
||||
this.emit('event', subId, event)
|
||||
break
|
||||
}
|
||||
case 'EOSE': {
|
||||
Relay.enforceArrayLength(array, 2)
|
||||
const [_, subId] = array
|
||||
this.emit('eose', subId)
|
||||
break
|
||||
}
|
||||
case 'NOTICE': {
|
||||
Relay.enforceArrayLength(array, 2)
|
||||
const [_, payload] = array
|
||||
this.emit('notice', payload)
|
||||
break
|
||||
}
|
||||
case 'OK': {
|
||||
Relay.enforceArrayLength(array, 4)
|
||||
const [_, eventId, wasSaved, message] = message
|
||||
this.emit('ok', eventId, wasSaved, message)
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown message type '${type}'`)
|
||||
}
|
||||
}
|
||||
|
||||
static enforceArrayLength(array, length) {
|
||||
if (array.length !== length) {
|
||||
throw new Error(`unexpected length (expected ${length}, got ${array.length})`)
|
||||
}
|
||||
if (array.some(elem => elem === undefined)) {
|
||||
throw new Error(`required element missing (${length} needed)`)
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.url
|
||||
}
|
||||
}
|
||||
|
||||
class ReconnectingWebSocket extends Observable {
|
||||
constructor(url, opts) {
|
||||
super()
|
||||
this.url = url
|
||||
this.opts = Object.assign({
|
||||
reconnect: true,
|
||||
reconnectAfter: 3000,
|
||||
}, opts)
|
||||
|
||||
this.socket = null
|
||||
this.disconnected = false
|
||||
this.reconnectAfter = this.opts.reconnectAfter
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.socket) return
|
||||
this.disconnected = false
|
||||
|
||||
const ws = new WebSocket(this.url)
|
||||
ws.onopen = this.onOpen.bind(this)
|
||||
ws.onclose = this.onClose.bind(this)
|
||||
ws.onerror = this.onError.bind(this)
|
||||
ws.onmessage = this.onMessage.bind(this)
|
||||
this.socket = ws
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.disconnected = true
|
||||
if (this.socket) this.socket.close()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
if (this.disconnected || this.reconnectTimer) return
|
||||
|
||||
this.reconnectTimer = setTimeout(
|
||||
() => {
|
||||
this.connect()
|
||||
this.reconnectTimer = null
|
||||
},
|
||||
this.reconnectAfter
|
||||
)
|
||||
|
||||
this.reconnectAfter *= 2
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.socket && this.socket.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
send(message) {
|
||||
// TODO Wait for connected?
|
||||
if (!this.isConnected()) {
|
||||
console.warn(`Not connected to ${this.url} (currently ${this.socket?.readyState})`)
|
||||
return
|
||||
}
|
||||
|
||||
this.socket.send(JSON.stringify(message))
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
this.emit('open', this)
|
||||
this.reconnectAfter = this.opts.reconnectAfter
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.emit('close', this)
|
||||
if (this.opts.reconnect) this.reconnect()
|
||||
}
|
||||
|
||||
onError(error) {
|
||||
console.log(`Socket error from relay ${this.url}: ${error.message || error}`)
|
||||
|
||||
this.emit('error', error, this)
|
||||
if (this.opts.reconnect) this.reconnect()
|
||||
}
|
||||
|
||||
onMessage(message) {
|
||||
this.emit('message', message, this)
|
||||
}
|
||||
}
|
83
src/nostr/RelayPool.js
Normal file
83
src/nostr/RelayPool.js
Normal file
@ -0,0 +1,83 @@
|
||||
import {Relay} from 'src/nostr/Relay'
|
||||
import {Observable} from 'src/nostr/utils'
|
||||
|
||||
export default class extends Observable {
|
||||
constructor(urls) {
|
||||
super()
|
||||
|
||||
this.relays = {}
|
||||
this.subs = {}
|
||||
|
||||
for (const url of urls) {
|
||||
this.add(url)
|
||||
}
|
||||
}
|
||||
|
||||
add(url) {
|
||||
if (this.relays[url]) return
|
||||
|
||||
const relay = new Relay(url)
|
||||
relay.on('open', this.onOpen.bind(this))
|
||||
relay.on('close', this.emit.bind(this, 'close', relay))
|
||||
|
||||
relay.on('event', this.emit.bind(this, 'event', relay))
|
||||
relay.on('eose', this.emit.bind(this, 'eose', relay))
|
||||
relay.on('notice', this.emit.bind(this, 'notice', relay))
|
||||
relay.on('ok', this.emit.bind(this, 'ok', relay))
|
||||
|
||||
this.relays[url] = relay
|
||||
}
|
||||
|
||||
remove(url) {
|
||||
const relay = this.relays[url]
|
||||
if (!relay) return
|
||||
relay.disconnect()
|
||||
delete this.relays[url]
|
||||
}
|
||||
|
||||
send(event) {
|
||||
for (const relay of this.connectedRelays()) {
|
||||
relay.send(event)
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(subId, filters) {
|
||||
// console.log(`Subscribing ${subId}`, filters)
|
||||
this.subs[subId] = filters
|
||||
for (const relay of this.connectedRelays()) {
|
||||
relay.subscribe(subId, filters)
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe(subId) {
|
||||
delete this.subs[subId]
|
||||
for (const relay of this.connectedRelays()) {
|
||||
relay.unsubscribe(subId)
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
for (const relay of Object.values(this.relays)) {
|
||||
relay.connect()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
for (const relay of Object.values(this.relays)) {
|
||||
relay.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
connectedRelays() {
|
||||
return Object.values(this.relays).filter(relay => relay.isConnected())
|
||||
}
|
||||
|
||||
onOpen(relay) {
|
||||
console.log(`Connected to ${relay}`, relay)
|
||||
for (const subId of Object.keys(this.subs)) {
|
||||
console.log(`Subscribing ${subId} with ${relay}`, this.subs[subId])
|
||||
relay.subscribe(subId, this.subs[subId])
|
||||
}
|
||||
this.emit('open', relay)
|
||||
}
|
||||
}
|
69
src/nostr/model/Event.js
Normal file
69
src/nostr/model/Event.js
Normal file
@ -0,0 +1,69 @@
|
||||
export const EventKind = {
|
||||
METADATA: 0,
|
||||
NOTE: 1,
|
||||
RELAY: 2,
|
||||
CONTACT: 3,
|
||||
DM: 4,
|
||||
DELETE: 5,
|
||||
SHARE: 6,
|
||||
REACTION: 7,
|
||||
CHATROOM: 42,
|
||||
}
|
||||
|
||||
export const Tag = {
|
||||
PUBKEY: 'p',
|
||||
EVENT: 'e',
|
||||
}
|
||||
|
||||
class EventRefs extends Array {
|
||||
constructor(refs) {
|
||||
// FIXME limit number of refs here
|
||||
super(...refs)
|
||||
}
|
||||
|
||||
root() {
|
||||
return this[0]
|
||||
}
|
||||
|
||||
ancestor() {
|
||||
return this[this.length - 1]
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
export default class Event {
|
||||
constructor(args) {
|
||||
this.id = args.id
|
||||
this.pubkey = args.pubkey
|
||||
this.createdAt = args.createdAt || args.created_at
|
||||
this.kind = args.kind
|
||||
this.tags = args.tags || []
|
||||
this.content = args.content
|
||||
this.sig = args.sig
|
||||
}
|
||||
|
||||
static from(obj) {
|
||||
return new Event(obj)
|
||||
}
|
||||
|
||||
validate() {
|
||||
// TODO
|
||||
return true
|
||||
}
|
||||
|
||||
pubkeyRefs() {
|
||||
return this.tags
|
||||
.filter(tag => tag[0] === Tag.PUBKEY && tag[1])
|
||||
.map(tag => tag[1])
|
||||
}
|
||||
|
||||
eventRefs() {
|
||||
const refs = this.tags
|
||||
.filter(tag => tag[0] === Tag.EVENT && tag[1])
|
||||
.map(tag => tag[1])
|
||||
return new EventRefs(refs)
|
||||
}
|
||||
}
|
39
src/nostr/model/Note.js
Normal file
39
src/nostr/model/Note.js
Normal file
@ -0,0 +1,39 @@
|
||||
import {EventKind} from 'src/nostr/model/Event'
|
||||
|
||||
export default class Note {
|
||||
constructor(id, args) {
|
||||
this.id = id
|
||||
this.author = args.author || args.pubkey
|
||||
this.createdAt = args.createdAt
|
||||
this.content = args.content
|
||||
this.refs = {
|
||||
events: args.refs?.events || [],
|
||||
pubkeys: args.refs?.pubkeys || [],
|
||||
}
|
||||
}
|
||||
|
||||
static from(event) {
|
||||
console.assert(event.kind === EventKind.NOTE)
|
||||
return new Note(event.id, {
|
||||
author: event.pubkey,
|
||||
createdAt: event.createdAt,
|
||||
content: event.content,
|
||||
refs: {
|
||||
events: event.eventRefs(),
|
||||
pubkeys: event.pubkeyRefs(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
isReply() {
|
||||
return !this.refs.events.isEmpty()
|
||||
}
|
||||
|
||||
root() {
|
||||
return this.refs.events.root()
|
||||
}
|
||||
|
||||
ancestor() {
|
||||
return this.refs.events.ancestor()
|
||||
}
|
||||
}
|
27
src/nostr/model/Profile.js
Normal file
27
src/nostr/model/Profile.js
Normal file
@ -0,0 +1,27 @@
|
||||
import {EventKind} from 'src/nostr/model/Event'
|
||||
|
||||
export default class Profile {
|
||||
constructor(pubkey, lastUpdatedAt, metadata) {
|
||||
this.pubkey = pubkey
|
||||
this.lastUpdatedAt = lastUpdatedAt
|
||||
|
||||
this.name = metadata.name
|
||||
this.about = metadata.about
|
||||
this.picture = metadata.picture
|
||||
this.nip05 = {
|
||||
url: metadata.nip05,
|
||||
verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
static from(event) {
|
||||
console.assert(event.kind === EventKind.METADATA)
|
||||
try {
|
||||
const metadata = JSON.parse(event.content)
|
||||
return new Profile(event.pubkey, event.createdAt, metadata)
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse METADATA event: ${e.message || e}`, event, e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
51
src/nostr/store/NoteStore.js
Normal file
51
src/nostr/store/NoteStore.js
Normal file
@ -0,0 +1,51 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import Note from 'src/nostr/model/Note'
|
||||
|
||||
export const NoteOrder = {
|
||||
CREATION_DATE_ASC: (a, b) => a.createdAt - b.createdAt,
|
||||
CREATION_DATE_DESC: (a, b) => b.createdAt - a.createdAt,
|
||||
}
|
||||
|
||||
export const useNoteStore = defineStore('note', {
|
||||
state: () => ({
|
||||
notes: {},
|
||||
replies: {},
|
||||
byAuthor: {},
|
||||
}),
|
||||
getters: {
|
||||
get(state) {
|
||||
return id => state.notes[id]
|
||||
},
|
||||
repliesTo(state) {
|
||||
return (id, order) => (state.replies[id] || []).sort(order || NoteOrder.CREATION_DATE_ASC)
|
||||
},
|
||||
allByAuthor(state) {
|
||||
return (pubkey, order) => (state.byAuthor[pubkey] || []).sort(order || NoteOrder.CREATION_DATE_ASC)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
addEvent(event) {
|
||||
const note = Note.from(event)
|
||||
if (!note) return false
|
||||
|
||||
// Skip if note already exists
|
||||
if (this.notes[note.id]) return this.notes[note.id]
|
||||
|
||||
this.notes[note.id] = note
|
||||
|
||||
if (!this.byAuthor[note.author]) {
|
||||
this.byAuthor[note.author] = []
|
||||
}
|
||||
this.byAuthor[note.author].push(note)
|
||||
|
||||
if (note.isReply()) {
|
||||
if (!this.replies[note.ancestor()]) {
|
||||
this.replies[note.ancestor()] = []
|
||||
}
|
||||
this.replies[note.ancestor()].push(note)
|
||||
}
|
||||
|
||||
return this.notes[note.id]
|
||||
}
|
||||
}
|
||||
})
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user