Hamstr v2 initial commit

This commit is contained in:
styppo 2023-01-07 03:10:26 +00:00
commit 61e78904d4
No known key found for this signature in database
GPG Key ID: 3AAA685C50724C28
113 changed files with 12277 additions and 0 deletions

9
.editorconfig Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
# pnpm-related options
shamefully-hoist=true
strict-peer-dependencies=false

9
.postcssrc.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

240
quasar.config.js Normal file
View 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
View 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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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'
},
]

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

View 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>&#183;</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>

View 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>&#183;</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>

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

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

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

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

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

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

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

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

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

View 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&nbsp;<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>

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

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

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

@ -0,0 +1,5 @@
import enUS from './en-US'
export default {
'en-US': enUS
}

22
src/index.template.html Normal file
View 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
View 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>

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

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

View 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