Add options framework

This commit is contained in:
mashirozx 2021-07-18 19:52:09 +08:00
parent ef505acbb1
commit 565aeaf41f
45 changed files with 1491 additions and 243 deletions

204
app/configs/options.json Normal file
View File

@ -0,0 +1,204 @@
{
"basic.site.title": {
"namespace": "basic.site.title",
"public": true,
"title": "Site title",
"desc": "The site title",
"type": "string",
"default": "Theme Sakura"
},
"basic.site.logo": {
"namespace": "basic.site.logo",
"public": true,
"title": "Site logo",
"desc": "The site's Logo image, will display on navigation bar.",
"type": "mediaPicker",
"default": [
{
"id": 0,
"url": "https://v3.vuejs.org/logo.png"
}
],
"binds": {
"title": "Select image for site logo.",
"button": "Use this image",
"type": "image",
"multiple": false
}
},
"social.github": {
"namespace": "social.github",
"public": true,
"title": "Github username",
"desc": "Your <a href=\"https://github.com\" target=\"_blank\">Github</a> username",
"type": "string",
"default": ""
},
"thirdParty.reCaptcha.enable": {
"namespace": "thirdParty.reCaptcha.enable",
"public": true,
"title": "Enable reCAPTCHA",
"desc": "Use reCAPTCHA for anti-spam check.",
"type": "switcher",
"default": false,
"binds": {
"positiveLabel": "Enabled",
"negativeLabel": "Disabled",
"disabled": false
}
},
"thirdParty.reCaptcha.version": {
"namespace": "thirdParty.reCaptcha.version",
"public": true,
"title": "reCAPTCHA version",
"desc": "Register your reCAPTCHA app '<a href=\"https://www.google.com/recaptcha/about/\" target=\"_blank\">here</a>', and choose a version.",
"type": "choose",
"default": null,
"binds": {
"options": [
{
"label": "reCAPTCHA version 3",
"disabled": false
},
{
"label": "reCAPTCHA version 2",
"disabled": false
}
]
}
},
"thirdParty.reCaptcha.siteKey": {
"namespace": "thirdParty.reCaptcha.siteKey",
"public": true,
"title": "reCAPTCHA site key",
"type": "string",
"default": ""
},
"thirdParty.reCaptcha.secretKey": {
"namespace": "thirdParty.reCaptcha.secretKey",
"public": false,
"title": "reCAPTCHA secret key",
"type": "string",
"default": ""
},
"other.hello": {
"namespace": "other.hello",
"public": true,
"title": "Hello world",
"type": "string",
"default": "world"
},
"demo.string": {
"namespace": "demo.string",
"public": true,
"title": "String",
"desc": "One line string input.",
"type": "string",
"default": "Hello world!"
},
"demo.longString": {
"namespace": "demo.longString",
"public": true,
"title": "Long string",
"desc": "Textarea for long string input.",
"type": "longString",
"default": "\"It is the unknown we fear when we look upon death and darkness, nothing more.\"\n-- Albus Dumbledore"
},
"demo.switcher": {
"namespace": "demo.switcher",
"public": true,
"title": "Switcher",
"type": "switcher",
"desc": "True/False switcher.",
"default": true,
"binds": {
"positiveLabel": "current on",
"negativeLabel": "current off",
"disabled": false
}
},
"demo.choose": {
"namespace": "demo.choose",
"public": true,
"title": "Choose",
"desc": "Choose one from options.",
"type": "choose",
"default": null,
"binds": {
"options": [
{
"label": "op 1",
"disabled": false
},
{
"label": "op 2",
"disabled": false
},
{
"label": "op 3",
"disabled": false
},
{
"label": "op 4",
"disabled": true
}
]
}
},
"demo.selection": {
"namespace": "demo.selection",
"public": true,
"title": "Selection",
"desc": "Selection multiple items from options. max: {0: no limit, >0: limit}",
"type": "selection",
"default": [
true,
false,
true
],
"binds": {
"options": [
{
"label": "op 1",
"disabled": false
},
{
"label": "op 2",
"disabled": false
},
{
"label": "op 3",
"disabled": false
},
{
"label": "op 4",
"disabled": true
}
],
"max": 2
}
},
"demo.mediaPicker": {
"namespace": "demo.mediaPicker",
"public": true,
"title": "Media picker",
"desc": "<code>type=\"image\"|\"video\"|\"audio?\"</code>, the object must include id, id=0 for remote media.",
"type": "mediaPicker",
"default": [
{
"id": 0,
"url": "https://view.moezx.cc/images/2021/07/02/d5ab73174d18652d890e2f4d1b9bef8f.gif"
},
{
"id": 0,
"url": "https://view.moezx.cc/images/2021/07/02/a90553bf5b67770e87a89b2ce204eaa7.gif"
}
],
"binds": {
"title": "Select Media",
"button": "Use this media",
"type": "image",
"multiple": true
}
}
}

View File

@ -31,9 +31,8 @@ class InitStateController extends BaseController
'menus' => (new MenuController)->get_menus(), 'menus' => (new MenuController)->get_menus(),
// 'rewrite_rules' => (new \WP_Rewrite())->rewrite_rules(), // 'rewrite_rules' => (new \WP_Rewrite())->rewrite_rules(),
'index' => (new WP_Rewrite())->index, 'index' => (new WP_Rewrite())->index,
'config' => (new ConfigurationController)->public_options(), 'config' => (new OptionController)->get_public_display_options(),
'recaptcha_site_key' => '6LfHEoEbAAAAAI5p_XBlr1WxEvrsOSNQFCQNcT79', // v2 secret key: 6LfHEoEbAAAAAIh0w2I9PCcVoa0j71mO6t7fipsj // 'recaptcha_site_key' => sakura_options('thirdParty.reCaptcha.siteKey', ''), // use thirdParty.reCaptcha.siteKey
// 'recaptcha_site_key' => '6LdKhX8bAAAAAF5HJprXtKvg3nfBJMfgd2o007PN' // v3 secret key: 6LdKhX8bAAAAAA010EXlQ32FWoYD1J2sLb8SaYLR
); );
} }

View File

@ -5,24 +5,10 @@ namespace Sakura\Controllers;
use WP_REST_Server; use WP_REST_Server;
use WP_REST_Request; use WP_REST_Request;
use WP_Error; use WP_Error;
use Sakura\Lib\Exception;
use Sakura\Models\OptionModel; use Sakura\Models\OptionModel;
class ConfigurationController extends BaseController class OptionController extends BaseController
{ {
public function public_options()
{
$keys = [
// key => default value
'title' => 'Theme Sakura',
];
$res = [];
foreach ($keys as $key => $default) {
$res[$key] = $this->sakura_options($key, $default);
}
return $res;
}
/** /**
* Constructor. * Constructor.
* *
@ -49,8 +35,8 @@ class ConfigurationController extends BaseController
array( array(
array( array(
'methods' => WP_REST_Server::READABLE, 'methods' => WP_REST_Server::READABLE,
'callback' => array($this, 'get_config'), 'callback' => array($this, 'get_public_config'),
'permission_callback' => array($this, 'get_config_permissions_check'), 'permission_callback' => array($this, 'get_public_config_permissions_check'),
// 'args' => $this->get_collection_params(), // 'args' => $this->get_collection_params(),
), ),
array( array(
@ -64,6 +50,16 @@ class ConfigurationController extends BaseController
); );
} }
public function get_public_config(WP_REST_Request $request)
{
return $this->get_public_display_options();
}
public function get_public_config_permissions_check(WP_REST_Request $request)
{
return true;
}
public function get_config(WP_REST_Request $request) public function get_config(WP_REST_Request $request)
{ {
$config = (array) OptionModel::get($this->rest_base); $config = (array) OptionModel::get($this->rest_base);
@ -85,13 +81,30 @@ class ConfigurationController extends BaseController
public function update_config(WP_REST_Request $request) public function update_config(WP_REST_Request $request)
{ {
$original = (array) $this->get_config($request); $db = (array) $this->get_config($request);
$cache = $db;
$json = (array) self::json_validate($request->get_body()); $json = (array) self::json_validate($request->get_body());
if (empty(array_diff($original, $json))) { $hasNoDiff = true;
return $original;
foreach ($json as $key => $value) {
if (array_key_exists($key, $cache)) {
$nv = json_encode($value);
$ov = json_encode($cache[$key]);
if ($hasNoDiff) $hasNoDiff = $nv === $ov;
} else {
if ($hasNoDiff) $hasNoDiff = false;
}
$db[$key] = $value;
} }
$config = OptionModel::update($this->rest_base, $json); if ($hasNoDiff) {
return [
'code' => 'save_config_succeed',
'message' => __('Configurations already up to date.', self::$text_domain),
];
}
$config = OptionModel::update($this->rest_base, $db);
if (!$config) { if (!$config) {
return new WP_Error( return new WP_Error(
'save_config_failure', 'save_config_failure',
@ -99,7 +112,10 @@ class ConfigurationController extends BaseController
array('status' => 500) array('status' => 500)
); );
} else { } else {
return $this->get_config($request); return [
'code' => 'save_config_succeed',
'message' => __('Configurations saved successfully.', self::$text_domain),
];
} }
} }
@ -108,10 +124,10 @@ class ConfigurationController extends BaseController
return true; return true;
} }
public function inite_theme() // public function inite_theme()
{ // {
$config = OptionModel::create($this->rest_base, (array)[]); // $config = OptionModel::create($this->rest_base, (array)[]);
} // }
public static function json_validate(string $string) public static function json_validate(string $string)
{ {
@ -150,4 +166,38 @@ class ConfigurationController extends BaseController
// sprintf(__("No existing database saving value or default value for option '%s'.", self::$text_domain), $namespace) // sprintf(__("No existing database saving value or default value for option '%s'.", self::$text_domain), $namespace)
// ); // );
} }
public static function get_option_json()
{
$options = file_get_contents(__DIR__ . "/../configs/options.json");
return json_decode($options, true);
}
public function get_public_display_options()
{
$output = [];
$defaults = (array) self::get_option_json();
// return $defaults;
foreach ($defaults as $key => $value) {
if ($value['public']) {
$output[$value['namespace']] = $this->sakura_options($value['namespace'], $value['default']);
}
}
return $output;
}
/**
* Use in admin page only
* @return array
*/
public function get_all_options()
{
$output = [];
$defaults = (array) self::get_option_json();
// return $defaults;
foreach ($defaults as $key => $value) {
$output[$value['namespace']] = $this->sakura_options($value['namespace'], $value['default']);
}
return $output;
}
} }

View File

@ -24,6 +24,6 @@ new Sakura\Routers\PagesRouter();
function sakura_options(string $namespace, $default) function sakura_options(string $namespace, $default)
{ {
$CF = new Sakura\Controllers\ConfigurationController(); $CF = new Sakura\Controllers\OptionController();
return $CF->sakura_options($namespace, $default); return $CF->sakura_options($namespace, $default);
} }

View File

@ -4,6 +4,7 @@ namespace Sakura\Helpers;
use Sakura\Helpers\ViteHelper; use Sakura\Helpers\ViteHelper;
use Sakura\Controllers\InitStateController; use Sakura\Controllers\InitStateController;
use Sakura\Controllers\OptionController;
class AdminPageHelper extends ViteHelper class AdminPageHelper extends ViteHelper
{ {
@ -46,7 +47,11 @@ class AdminPageHelper extends ViteHelper
wp_enqueue_script('[type:module]dev-main', self::$development_host . '/src/admin/main.ts', array(), null, true); wp_enqueue_script('[type:module]dev-main', self::$development_host . '/src/admin/main.ts', array(), null, true);
wp_localize_script('[type:module]dev-main', 'AdminColors', $this->get_admin_color_css());
wp_localize_script('[type:module]dev-main', 'InitState', (new InitStateController())->get_initial_state()); wp_localize_script('[type:module]dev-main', 'InitState', (new InitStateController())->get_initial_state());
wp_localize_script('[type:module]dev-main', 'SakuraOptions', ['data' => (new OptionController())->get_all_options()]);
} }
public function enqueue_production_scripts() public function enqueue_production_scripts()
@ -56,9 +61,13 @@ class AdminPageHelper extends ViteHelper
$manifest = self::get_manifest_file('admin'); $manifest = self::get_manifest_file('admin');
// <script type="module" crossorigin src="http://localhost:9000/assets/index.36b06f45.js"></script> // <script type="module" crossorigin src="http://localhost:9000/assets/index.36b06f45.js"></script>
wp_enqueue_script('[type:module]chunk-vendors.js', $assets_base_path . $manifest[$entry_key]['file'], array(), null, false); wp_enqueue_script('[type:module]chunk-entrance.js', $assets_base_path . $manifest[$entry_key]['file'], array(), null, false);
wp_localize_script('[type:module]chunk-vendors.js', 'InitState', (new InitStateController())->get_initial_state()); wp_localize_script('[type:module]chunk-entrance.js', 'AdminColors', $this->get_admin_color_css());
wp_localize_script('[type:module]chunk-entrance.js', 'InitState', (new InitStateController())->get_initial_state());
wp_localize_script('[type:module]chunk-entrance.js', 'SakuraOptions', (new OptionController())->get_all_options());
// <link rel="modulepreload" href="http://localhost:9000/assets/vendor.b3a324ba.js"> // <link rel="modulepreload" href="http://localhost:9000/assets/vendor.b3a324ba.js">
foreach ($manifest[$entry_key]['imports'] as $index => $import) { foreach ($manifest[$entry_key]['imports'] as $index => $import) {
@ -85,4 +94,26 @@ class AdminPageHelper extends ViteHelper
wp_enqueue_style('fontawesome-free', 'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.15.3/css/all.min.css'); wp_enqueue_style('fontawesome-free', 'https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.15.3/css/all.min.css');
} }
public function get_admin_color_css()
{
// {"name":"Default","url":false,"colors":["#1d2327","#2c3338","#2271b1","#72aee6"],"icon_colors":{"base":"#a7aaad","focus":"#72aee6","current":"#fff"}}
global $_wp_admin_css_colors;
$theme = (array) $_wp_admin_css_colors[get_user_option('admin_color')];
// $scheme = [
// 'dark-primary' => $theme['colors'][0],
// 'dark-secondary' => $theme['colors'][1],
// 'light-primary' => $theme['colors'][2],
// 'light-secondary' => $theme['colors'][3],
// 'icon-base' => $theme['icon_colors']['base'],
// 'icon-focus' => $theme['icon_colors']['focus'],
// 'icon-current' => $theme['icon_colors']['current'],
// ];
return $theme;
// $css = '';
// foreach ($scheme as $key => $value) {
// $css .= "--{$key}:{$value};";
// }
// return $css;
}
} }

View File

@ -2,7 +2,7 @@
namespace Sakura\Helpers; namespace Sakura\Helpers;
use Sakura\Controllers\ConfigurationController; use Sakura\Controllers\OptionController;
class SetupHelper class SetupHelper
{ {
@ -22,7 +22,7 @@ class SetupHelper
// count post views // count post views
add_action('get_header', [$this, 'set_post_views']); add_action('get_header', [$this, 'set_post_views']);
// Inite config options // Inite config options
add_action('after_switch_theme', [new ConfigurationController(), 'inite_theme'], 1, 2); add_action('after_switch_theme', [new OptionController(), 'inite_theme'], 1, 2);
} }
public function setup() public function setup()

View File

@ -67,12 +67,7 @@ class ViteHelper extends BaseClass
wp_enqueue_style('normalize.css', 'https://cdn.jsdelivr.net/npm/normalize.css/normalize.css'); wp_enqueue_style('normalize.css', 'https://cdn.jsdelivr.net/npm/normalize.css/normalize.css');
// TODO: don't use vue.js as handler wp_enqueue_script('recaptcha', 'https://www.recaptcha.net/recaptcha/api.js', array(), false, true);
// wp_enqueue_script('vue.js', 'https://unpkg.com/vue@next', array(), false, false);
// wp_localize_script('vue.js', 'InitState', (new InitStateController())->get_initial_state());
wp_enqueue_script('recaptcha', 'https://www.recaptcha.net/recaptcha/api.js?render=6LdKhX8bAAAAAF5HJprXtKvg3nfBJMfgd2o007PN', array(), false, true);
} }
public static function script_tag_filter($tag, $handle, $src) public static function script_tag_filter($tag, $handle, $src)

View File

@ -4,7 +4,7 @@ namespace Sakura\Routers;
use WP_REST_Controller; use WP_REST_Controller;
use WP_REST_Server; use WP_REST_Server;
use Sakura\Controllers\ConfigurationController; use Sakura\Controllers\OptionController;
use Sakura\Controllers\InitStateController; use Sakura\Controllers\InitStateController;
use Sakura\Controllers\MenuController; use Sakura\Controllers\MenuController;
use Sakura\Controllers\PostController; use Sakura\Controllers\PostController;
@ -33,7 +33,9 @@ class ApiRouter extends WP_REST_Controller
*/ */
public function register_rest_routes() public function register_rest_routes()
{ {
add_action('rest_api_init', [new ConfigurationController(), 'register_routes']); // add options support
add_action('rest_api_init', [new OptionController(), 'register_routes']);
add_action('rest_api_init', function () { add_action('rest_api_init', function () {
// theme's initial states // theme's initial states
register_rest_route( register_rest_route(

View File

@ -2,6 +2,8 @@
namespace Sakura\Utils; namespace Sakura\Utils;
use Rogervila\ArrayDiffMultidimensional;
class Tools class Tools
{ {
public static function echo_interceptor(callable $callback, ...$args) public static function echo_interceptor(callable $callback, ...$args)
@ -13,15 +15,46 @@ class Tools
return $output; return $output;
} }
// public function get_text_from_dom($node, $text) { public static function get_text_from_dom($node, $text)
// if (!is_null($node->childNodes)) { {
// foreach ($node->childNodes as $node) { if (!is_null($node->childNodes)) {
// $text = get_text_from_dom($node, $text); foreach ($node->childNodes as $node) {
// } $text = self::get_text_from_dom($node, $text);
// } }
// else { } else {
// return $text . $node->textContent . ' '; return $text . $node->textContent . ' ';
// } }
// return $text; return $text;
// } }
/**
* https://stackoverflow.com/a/3877494/8083009
*
* @param array $aArray1
* @param array $aArray2
*
* @return array
*/
public static function array_recursive_diff(array $aArray1, array $aArray2)
{
$aReturn = array();
foreach ($aArray1 as $mKey => $mValue) {
if (array_key_exists($mKey, $aArray2)) {
if (is_array($mValue)) {
$aRecursiveDiff = self::array_recursive_diff($mValue, $aArray2[$mKey]);
if (count($aRecursiveDiff)) {
$aReturn[$mKey] = $aRecursiveDiff;
}
} else {
if ($mValue != $aArray2[$mKey]) {
$aReturn[$mKey] = $mValue;
}
}
} else {
$aReturn[$mKey] = $mValue;
}
}
return $aReturn;
}
} }

View File

@ -1,5 +1,5 @@
{% block admin_app %} {% block admin_app %}
<div id="app" class="sakura-options-page__app"> <div id="app" class="sakura-options-page__app" style="{{scheme}}">
Loading Loading
</div> </div>
{% endblock %} {% endblock %}

View File

@ -24,7 +24,8 @@
"rsync": "nodemon -e '*' --watch ./app --ignore ./app/vendor scripts/rsync.mjs", "rsync": "nodemon -e '*' --watch ./app --ignore ./app/vendor scripts/rsync.mjs",
"rsync:composer": "nodemon --watch './composer.json' --watch './composer.lock' scripts/rsync.mjs --composer", "rsync:composer": "nodemon --watch './composer.json' --watch './composer.lock' scripts/rsync.mjs --composer",
"gen:icon": "node scripts/import-svg-icons.mjs && eslint \"src/components/icon/**/*.{ts,js,json,vue}\" --fix && prettier \"src/components/icon/**/*.{ts,js,json,vue}\" --write", "gen:icon": "node scripts/import-svg-icons.mjs && eslint \"src/components/icon/**/*.{ts,js,json,vue}\" --fix && prettier \"src/components/icon/**/*.{ts,js,json,vue}\" --write",
"mdc": "node scripts/mdc-upgrade.mjs" "mdc": "node scripts/mdc-upgrade.mjs",
"options": "node scripts/options-export/copy-options.mjs && yarn tsc scripts/options-export/dump-options.ts && node scripts/options-export/dump-options.js"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl": "^1.13.2", "@formatjs/intl": "^1.13.2",
@ -49,6 +50,7 @@
"@yzfe/vue3-svgicon": "^1.0.1", "@yzfe/vue3-svgicon": "^1.0.1",
"axios": "^0.21.1", "axios": "^0.21.1",
"camelcase-keys": "^7.0.0", "camelcase-keys": "^7.0.0",
"chroma-js": "^2.1.2",
"crypto-js": "^4.0.0", "crypto-js": "^4.0.0",
"gsap": "^3.7.0", "gsap": "^3.7.0",
"highlight.js": "^11.1.0", "highlight.js": "^11.1.0",
@ -67,6 +69,7 @@
}, },
"devDependencies": { "devDependencies": {
"@formatjs/cli": "^4.2.27", "@formatjs/cli": "^4.2.27",
"@types/chroma-js": "^2.1.3",
"@types/crypto-js": "^4.0.1", "@types/crypto-js": "^4.0.1",
"@types/jest": "^26.0.24", "@types/jest": "^26.0.24",
"@types/marked": "^2.0.4", "@types/marked": "^2.0.4",
@ -87,7 +90,6 @@
"eslint-plugin-formatjs": "^2.17.1", "eslint-plugin-formatjs": "^2.17.1",
"eslint-plugin-prettier": "^3.3.1", "eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-vue": "^7.13.0", "eslint-plugin-vue": "^7.13.0",
"foreman": "^3.0.1",
"jest": "^27.0.6", "jest": "^27.0.6",
"nodemon": "^2.0.12", "nodemon": "^2.0.12",
"postcss-import": "^14.0.2", "postcss-import": "^14.0.2",

View File

@ -50,4 +50,4 @@ readdirSync(iconDir).forEach((file) => {
const vueContent = template(importContent, dataContent) const vueContent = template(importContent, dataContent)
writeFileSync(targetDir, vueContent) writeFileSync(targetDir, vueContent, { flag: 'w+' })

2
scripts/options-export/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.js
options.ts

View File

@ -0,0 +1,5 @@
import { readFileSync, writeFileSync } from 'fs'
let file = readFileSync('./src/admin/options.ts', { flag: 'r' }).toString()
file = file.replace('@/locales', './locales')
writeFileSync('./scripts/options-export/options.ts', file, { flag: 'w+' })

View File

@ -0,0 +1,15 @@
import options from './options'
import { writeFileSync } from 'fs'
const exportOptions: { [key: string]: any } = {}
Object.keys(options).forEach((tab) => {
options[tab].options.forEach((option) => {
if (option.depends) delete option.depends // remove function
exportOptions[option.namespace] = option
})
})
console.dir(exportOptions)
writeFileSync('./app/configs/options.json', JSON.stringify(exportOptions, null, 2), { flag: 'w+' })

View File

@ -0,0 +1,10 @@
// intl.formatMessage({
// id: 'options.basic.siteTitle',
// defaultMessage: 'The site title',
// })
export default {
formatMessage({ id, defaultMessage }: { id: string; defaultMessage: string }) {
return defaultMessage
},
}

View File

@ -1,29 +1,49 @@
<template> <template>
<div class="app__wrapper"> <div class="app__wrapper" :style="scheme">
<Core></Core> <div class="app__content">
<Core></Core>
</div>
</div>
<div class="messages__wrapper" :style="scheme">
<Messages position-y="bottom"></Messages>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import scheme from './scheme'
import Core from './Core.vue' import Core from './Core.vue'
import Messages from '@/components/messages/Messages.vue'
export default defineComponent({ export default defineComponent({
components: { Core }, components: { Core, Messages },
setup() {
return { scheme }
},
}) })
</script> </script>
<style lang="scss"> <style lang="scss">
@use './index'; @use './mdc';
@use './variables';
.sakura-options-page__app { .sakura-options-page__app {
width: calc(100% - 20px); width: calc(100% - 20px);
padding: 20px 20px 20px 0; padding: 20px 20px 20px 0;
@media screen and (max-width: 782px) { @media screen and (max-width: variables.$mobile-max-width) {
width: calc(100% - 10px); width: calc(100% - 10px);
padding: 10px 10px 10px 0; padding: 10px 10px 10px 0;
} }
> .app__wrapper { > .app__wrapper {
width: 100%; width: 100%;
.app__content {
width: 100%;
}
}
> .messages__wrapper {
position: fixed;
bottom: 0;
right: 0;
z-index: 999999;
} }
} }
</style> </style>

View File

@ -18,19 +18,28 @@
> >
<div class="tab-page__content"> <div class="tab-page__content">
<h1 class="row__wrapper--title">{{ options[tabKey].title }}</h1> <h1 class="row__wrapper--title">{{ options[tabKey].title }}</h1>
<p class="row__wrapper--desc" v-if="options[tabKey].desc"> {{ options[tabKey].desc }} </p> <p class="row__wrapper--desc" v-if="options[tabKey].desc" v-html="options[tabKey].desc">
<div </p>
class="row__wrapper--options" <transition-group name="row__wrapper--options">
v-for="(option, optionIndex) in options[tabKey].options" <div
:key="optionIndex" class="option__wrapper"
> v-for="(option, optionIndex) in options[tabKey].options"
<OptionItem :option="option"></OptionItem> :key="optionIndex"
</div> v-show="shouldOptionShow(option)"
>
<OptionItem :option="option"></OptionItem>
</div>
</transition-group>
</div> </div>
</SwiperSlide> </SwiperSlide>
</Swiper> </Swiper>
<div class="buttons__wrapper"> <div class="buttons__wrapper">
<NormalButton icon="fas fa-save" context="Save" :contained="true"></NormalButton> <NormalButton
:icon="['fas', saving ? 'fa-spinner fa-spin' : 'fa-save'].join(' ')"
context="Save"
:contained="true"
@click="handleSaveEvent"
></NormalButton>
<NormalButton icon="fas fa-upload" context="Import" :contained="true"></NormalButton> <NormalButton icon="fas fa-upload" context="Import" :contained="true"></NormalButton>
<NormalButton icon="fas fa-download" context="Export" :contained="true"></NormalButton> <NormalButton icon="fas fa-download" context="Export" :contained="true"></NormalButton>
</div> </div>
@ -38,21 +47,14 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { import { defineComponent, ref, Ref, watch, onMounted, onBeforeUnmount } from 'vue'
defineComponent,
ref,
Ref,
watch,
nextTick,
watchEffect,
onMounted,
onBeforeUnmount,
} from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue' import { Swiper, SwiperSlide } from 'swiper/vue'
import { Swiper as SwiperInterface } from 'swiper' import { Swiper as SwiperInterface } from 'swiper'
import { useInjector } from '@/hooks' import { useInjector, useState, useMessage } from '@/hooks'
import store from './store' import store from './store'
import options from '@/admin/options' import options from './options'
import type { Option } from './options'
import API from './api'
import TabBar from '@/components/tabBar/TabBar.vue' import TabBar from '@/components/tabBar/TabBar.vue'
import OptionItem from './OptionItem.vue' import OptionItem from './OptionItem.vue'
import NormalButton from '@/components/buttons/NormalButton.vue' import NormalButton from '@/components/buttons/NormalButton.vue'
@ -61,32 +63,85 @@ export default defineComponent({
components: { TabBar, Swiper, SwiperSlide, OptionItem, NormalButton }, components: { TabBar, Swiper, SwiperSlide, OptionItem, NormalButton },
setup() { setup() {
// UI controllers // UI controllers
const currentTabIndex: Ref<number> = ref(0)
const swiperRef: Ref<SwiperInterface | null> = ref(null)
const tabKeys = Object.keys(options) const tabKeys = Object.keys(options)
const tabs = tabKeys.map((key) => { const tabs = tabKeys.map((key) => {
return { context: options[key].title, icon: options[key].icon } return { context: options[key].title, icon: options[key].icon, key }
}) })
let defaultCurrentTabIndex: number = 0
if (window.location.hash) {
const locationHashMatch = window.location.hash.match(/^#(.*)/)
if (locationHashMatch && locationHashMatch[1] && tabKeys.indexOf(locationHashMatch[1]) > -1) {
defaultCurrentTabIndex = tabKeys.indexOf(locationHashMatch[1])
}
}
const currentTabIndex: Ref<number> = ref(defaultCurrentTabIndex)
const swiperRef: Ref<SwiperInterface | null> = ref(null)
const handleSwiperEvent = (swiper: SwiperInterface) => { const handleSwiperEvent = (swiper: SwiperInterface) => {
swiperRef.value = swiper swiperRef.value = swiper
swiper.slideTo(currentTabIndex.value)
} }
watch(currentTabIndex, (current) => swiperRef.value?.slideTo(current)) watch(currentTabIndex, (current) => {
swiperRef.value?.slideTo(current)
window.location.hash = `#${tabs[current].key}`
})
const updateAutoHeight = () => swiperRef.value?.updateAutoHeight(0) const updateAutoHeight = (timeout = 0) => swiperRef.value?.updateAutoHeight(timeout)
// nextTick(() => updateAutoHeight())
// watchEffect(() => updateAutoHeight())
// auto update height
onMounted(() => { onMounted(() => {
const timer = setInterval(() => updateAutoHeight(), 100) const timer = setInterval(() => updateAutoHeight(100), 100)
onBeforeUnmount(() => clearInterval(timer)) onBeforeUnmount(() => clearInterval(timer))
}) })
// data controllers // messages
const { config, setConfig } = useInjector(store) const addMessage = useMessage()
return { currentTabIndex, tabKeys, tabs, options, handleSwiperEvent } // data controllers
const [saving, setSaving] = useState(false)
const { config } = useInjector(store)
const handleSaveEvent = () => {
if (saving.value) return
setSaving(true)
API.postConfigJson(config.value)
.then((res) => {
setSaving(false)
addMessage({
title: res.data.message,
type: 'success',
})
})
.catch((error) => {
setSaving(false)
console.error(error)
addMessage({
title: error.toString(),
type: 'error',
})
})
}
const shouldOptionShow = (option: Option) => {
if (option.depends) {
return option.depends(config)
} else {
return true
}
}
return {
currentTabIndex,
tabKeys,
tabs,
options,
handleSwiperEvent,
handleSaveEvent,
shouldOptionShow,
saving,
}
}, },
}) })
</script> </script>
@ -110,6 +165,44 @@ export default defineComponent({
.tab-page__content { .tab-page__content {
width: calc(100% - 24px); width: calc(100% - 24px);
padding: 12px; padding: 12px;
.row__wrapper {
&--options {
&-enter-active {
transform-origin: top;
animation: from 0.3s forwards;
}
&-leave-active {
transform-origin: top;
animation: to 0.3s forwards;
}
&-move {
transition: transform 0.3s ease;
}
}
}
@keyframes from {
0% {
transform: scaleY(0);
opacity: 0;
}
100% {
transform: scaleY(1);
opacity: 1;
}
}
@keyframes to {
0% {
height: 56px;
transform: scaleY(1);
opacity: 1;
}
100% {
height: 0;
transform: scaleY(0);
opacity: 0;
}
}
} }
} }
} }

View File

@ -80,11 +80,17 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use './variables';
.option__container { .option__container {
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
align-items: space-between; align-items: space-between;
justify-content: flex-start; justify-content: flex-start;
@media screen and (max-width: variables.$mobile-max-width) {
flex-flow: column nowrap;
align-items: flex-start;
justify-content: flex-start;
}
> .column__wrapper { > .column__wrapper {
&--label { &--label {
flex: 0 0 auto; flex: 0 0 auto;
@ -96,7 +102,7 @@ export default defineComponent({
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-flow: column nowrap; flex-flow: column nowrap;
align-items: space-between; align-items: flex-start;
justify-content: flex-start; justify-content: flex-start;
padding-top: 12px; padding-top: 12px;
> .row__wrapper { > .row__wrapper {

View File

@ -2,18 +2,13 @@
@use '@material/elevation/mdc-elevation'; @use '@material/elevation/mdc-elevation';
@use '@material/button/mdc-button'; @use '@material/button/mdc-button';
@use "@material/textfield/mdc-text-field"; @use "@material/textfield/mdc-text-field";
// @use '@material/chips/deprecated/mdc-chips';
// @use '@material/list/mdc-list';
@use '@material/card/mdc-card'; @use '@material/card/mdc-card';
@use "@material/tab-bar/mdc-tab-bar"; @use "@material/tab-bar/mdc-tab-bar";
@use "@material/tab-scroller/mdc-tab-scroller"; @use "@material/tab-scroller/mdc-tab-scroller";
@use "@material/tab-indicator/mdc-tab-indicator"; @use "@material/tab-indicator/mdc-tab-indicator";
@use "@material/tab/mdc-tab"; @use "@material/tab/mdc-tab";
// @use '@material/typography/mdc-typography';
@use "@material/checkbox/mdc-checkbox"; @use "@material/checkbox/mdc-checkbox";
// @use "@material/form-field/mdc-form-field";
@use "@material/radio/mdc-radio"; @use "@material/radio/mdc-radio";
// @use "@material/switch/deprecated/mdc-switch";
@use '@material/switch/styles'; @use '@material/switch/styles';

22
src/admin/_scheme.scss Normal file
View File

@ -0,0 +1,22 @@
/**
* @deprecated
*/
@mixin global-variables {
// Default
--dark-primary: #1d2327;
--dark-secondary: #2c3338;
--light-primary: #2271b1;
--light-secondary: #72aee6;
--icon-base: #a7aaad;
--icon-focus: #72aee6;
--icon-current: #fff;
// Light
--dark-primary: #e5e5e5; // menu background color
--dark-secondary: #999; // menu focus background color
--light-primary: #d64e07; // hot dot
--light-secondary: #04a4cc; // button, link
--icon-base: #999;
--icon-focus: #ccc;
--icon-current: #ccc;
}

View File

@ -0,0 +1,2 @@
$mobile-max-width: 782px;
$small-mobile-max-width: 466px;

View File

@ -1,7 +1,7 @@
import request from '@/utils/http' import request from '@/utils/http'
export default { export default {
postConfigJson(data: any): Promise<any> { postConfigJson(data: { [key: string]: any }): Promise<any> {
return request({ return request({
url: '/sakura/v1/config', url: '/sakura/v1/config',
method: 'POST', method: 'POST',

View File

@ -27,6 +27,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, watch, Ref } from 'vue' import { defineComponent, ref, watch, Ref } from 'vue'
import { cloneDeep, remove } from 'lodash' import { cloneDeep, remove } from 'lodash'
import { useMessage, useIntl } from '@/hooks'
import uniqueHash from '@/utils/uniqueHash' import uniqueHash from '@/utils/uniqueHash'
import { isUrl } from '@/utils/urlHelper' import { isUrl } from '@/utils/urlHelper'
import NormalButton from '@/components/buttons/NormalButton.vue' import NormalButton from '@/components/buttons/NormalButton.vue'
@ -43,6 +44,9 @@ export default defineComponent({
}, },
emits: ['update:selection'], emits: ['update:selection'],
setup(props, { emit }) { setup(props, { emit }) {
const addMessage = useMessage()
const intl = useIntl()
const selection: Ref<{ id: number; url: string }[]> = ref( const selection: Ref<{ id: number; url: string }[]> = ref(
props.selection as { id: number; url: string }[] props.selection as { id: number; url: string }[]
) )
@ -103,12 +107,22 @@ export default defineComponent({
selection.value.push({ id: 0, url }) selection.value.push({ id: 0, url })
userInput.value = '' userInput.value = ''
} else { } else {
// TODO addMessage({
console.warn('Duplicate URLs') title: intl.formatMessage({
id: 'messages.admin.uplicateUrls',
defaultMessage: 'Duplicate URLs',
}),
type: 'warning',
})
} }
} else { } else {
// TODO addMessage({
console.warn('Invalid URL') title: intl.formatMessage({
id: 'messages.admin.invalidUrl',
defaultMessage: 'Invalid URL',
}),
type: 'error',
})
} }
} }
@ -116,7 +130,18 @@ export default defineComponent({
remove(selection.value, (item, itemIndex) => index === itemIndex) remove(selection.value, (item, itemIndex) => index === itemIndex)
} }
watch(selection, (value) => emit('update:selection', value), { deep: true }) watch(
selection,
(value) => {
if (!props.multiple && value.length > 1) {
selection.value = selection.value.slice(-1)
console.log(selection.value.length)
}
console.log(selection.value)
emit('update:selection', selection.value)
},
{ deep: true }
)
return { open, add, del, userInput, selection } return { open, add, del, userInput, selection }
}, },
@ -124,6 +149,7 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use '../variables';
.picker__container { .picker__container {
width: 100%; width: 100%;
display: flex; display: flex;
@ -148,6 +174,13 @@ export default defineComponent({
> .button__wrapper { > .button__wrapper {
flex: 0 0 auto; flex: 0 0 auto;
} }
@media screen and (max-width: variables.$small-mobile-max-width) {
flex-flow: row wrap;
justify-content: flex-start;
> .input__wrapper {
flex: 0 0 auto;
}
}
} }
&--preview { &--preview {
display: flex; display: flex;
@ -179,7 +212,7 @@ export default defineComponent({
justify-content: center; justify-content: center;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 1;
transition: all 0.3s ease-in-out; transition: all 0.3s ease-in-out;
} }
&:hover { &:hover {

View File

@ -4,12 +4,13 @@ import '@yzfe/svgicon/lib/svgicon.css'
import App from './App.vue' import App from './App.vue'
import { storeProviderPlugin } from '@/hooks/store' import { storeProviderPlugin } from '@/hooks/store'
import store from './store' import store from './store'
import { messages } from '@/store'
import { intlPlugin } from '../locales' import { intlPlugin } from '../locales'
import UiIcon from '@/components/icon/UiIcon.vue' import UiIcon from '@/components/icon/UiIcon.vue'
import Image from '@/components/image/Image.vue' import Image from '@/components/image/Image.vue'
const app = createApp(App) const app = createApp(App)
app.use(storeProviderPlugin, [store]) app.use(storeProviderPlugin, [store, messages])
app.use(intlPlugin) app.use(intlPlugin)
app.use(VueSvgIconPlugin, { tagName: 'svg-icon' }) app.use(VueSvgIconPlugin, { tagName: 'svg-icon' })
app.component('UiIcon', UiIcon) app.component('UiIcon', UiIcon)

View File

@ -1,16 +1,21 @@
import intl from '@/locales'
export interface Option {
namespace: string
public: boolean
title: string
desc?: string
type: string
default: any
binds?: { [key: string]: any }
depends?: (state: any) => boolean
}
export interface Options { export interface Options {
[tag: string]: { [tag: string]: {
title: string title: string
desc?: string desc?: string
icon: string icon: string
options: Array<{ options: Array<Option>
namespace: string
title: string
desc?: string
type: string
default: any
binds?: { [key: string]: any }
}>
} }
} }
@ -20,17 +25,201 @@ const options: Options = {
desc: 'The basic options', desc: 'The basic options',
icon: 'fas fa-address-card', icon: 'fas fa-address-card',
options: [ options: [
// basic.site.title
{ {
namespace: 'basic.siteTitle', namespace: 'basic.site.title',
title: 'Site title', public: true,
desc: 'The site title', title: intl.formatMessage({
id: 'options.basic.site.title.title',
defaultMessage: 'Site title',
}),
desc: intl.formatMessage({
id: 'options.basic.site.title.desc',
defaultMessage: 'The site title',
}),
type: 'string', type: 'string',
default: 'Opps', default: 'Theme Sakura',
},
// basic.site.logo
{
namespace: 'basic.site.logo',
public: true,
title: intl.formatMessage({
id: 'options.basic.site.logo.title',
defaultMessage: 'Site logo',
}),
desc: intl.formatMessage({
id: 'options.basic.site.logo.desc',
defaultMessage: "The site's Logo image, will display on navigation bar.",
}),
type: 'mediaPicker',
default: [{ id: 0, url: 'https://v3.vuejs.org/logo.png' }],
binds: {
title: intl.formatMessage({
id: 'options.basic.site.logo.binds.title',
defaultMessage: 'Select image for site logo.',
}),
button: intl.formatMessage({
id: 'options.basic.site.logo.binds.button',
defaultMessage: 'Use this image',
}),
type: 'image',
multiple: false,
},
},
],
},
social: {
title: 'Social',
icon: 'fas fa-users',
options: [
{
namespace: 'social.github',
public: true,
title: 'Github username',
desc: 'Your <a href="https://github.com" target="_blank">Github</a> username',
type: 'string',
default: '',
},
],
},
thirdParty: {
title: 'Third party services',
icon: 'fas fa-bezier-curve',
options: [
// thirdParty.reCaptcha.enable
{
namespace: 'thirdParty.reCaptcha.enable',
public: true,
title: intl.formatMessage({
id: 'options.thirdParty.reCaptcha.enable.title',
defaultMessage: 'Enable reCAPTCHA',
}),
desc: intl.formatMessage({
id: 'options.thirdParty.reCaptcha.enable.desc',
defaultMessage: 'Use reCAPTCHA for anti-spam check.',
}),
type: 'switcher',
default: false,
binds: {
positiveLabel: intl.formatMessage({
id: 'options.thirdParty.reCaptcha.enable.positiveLabel',
defaultMessage: 'Enabled',
}),
negativeLabel: intl.formatMessage({
id: 'options.thirdParty.reCaptcha.enable.negativeLabel',
defaultMessage: 'Disabled',
}),
disabled: false,
},
},
// thirdParty.reCaptcha.version
{
namespace: 'thirdParty.reCaptcha.version',
public: true,
title: intl.formatMessage({
id: 'options.thirdParty.reCaptcha.version.title',
defaultMessage: 'reCAPTCHA version',
}),
desc: intl.formatMessage({
id: 'options.thirdParty.reCaptcha.version.desc',
defaultMessage:
'Register your reCAPTCHA app \'<a href="https://www.google.com/recaptcha/about/" target="_blank">here</a>\', and choose a version.',
}),
type: 'choose',
default: NaN,
binds: {
options: [
{
label: intl.formatMessage({
id: 'options.thirdParty.reCaptcha.version.label.v3',
defaultMessage: 'reCAPTCHA version 3',
}),
disabled: false,
},
{
label: intl.formatMessage({
id: 'options.thirdParty.reCaptcha.version.label.v2',
defaultMessage: 'reCAPTCHA version 2',
}),
disabled: false,
},
],
},
depends: (state) => {
return state.value['thirdParty.reCaptcha.enable']
},
},
// thirdParty.reCaptcha.siteKey
{
namespace: 'thirdParty.reCaptcha.siteKey',
public: true,
title: intl.formatMessage({
id: 'options.thirdParty.reCaptcha.siteKey.title',
defaultMessage: 'reCAPTCHA site key',
}),
type: 'string',
default: '',
depends: (state) => {
return state.value['thirdParty.reCaptcha.enable']
},
},
// thirdParty.reCaptcha.secretKey
{
namespace: 'thirdParty.reCaptcha.secretKey',
public: false,
title: intl.formatMessage({
id: 'options.thirdParty.reCaptcha.secretKey.title',
defaultMessage: 'reCAPTCHA secret key',
}),
type: 'string',
default: '',
depends: (state) => {
return state.value['thirdParty.reCaptcha.enable']
},
},
],
},
other: {
title: 'Other',
icon: 'fas fa-umbrella',
options: [
{
namespace: 'other.hello',
public: true,
title: 'Hello world',
type: 'string',
default: 'world',
},
],
},
demos: {
title: 'Demo',
icon: 'fas fa-democrat',
desc: '<i class="fas fa-exclamation-triangle"></i> Just components demo, comment this section in prod mode!',
options: [
{
namespace: 'demo.string',
public: true,
title: 'String',
desc: 'One line string input.',
type: 'string',
default: 'Hello world!',
}, },
{ {
namespace: 'basic.switcher', namespace: 'demo.longString',
public: true,
title: 'Long string',
desc: 'Textarea for long string input.',
type: 'longString',
default: `"It is the unknown we fear when we look upon death and darkness, nothing more."\n-- Albus Dumbledore`,
},
{
namespace: 'demo.switcher',
public: true,
title: 'Switcher', title: 'Switcher',
type: 'switcher', type: 'switcher',
desc: 'True/False switcher.',
default: true, default: true,
binds: { binds: {
positiveLabel: 'current on', positiveLabel: 'current on',
@ -39,9 +228,10 @@ const options: Options = {
}, },
}, },
{ {
namespace: 'basic.chooseTest', namespace: 'demo.choose',
title: 'Choose Test', public: true,
desc: 'wooooo', title: 'Choose',
desc: 'Choose one from options.',
type: 'choose', type: 'choose',
default: NaN, default: NaN,
binds: { binds: {
@ -51,13 +241,13 @@ const options: Options = {
{ label: 'op 3', disabled: false }, { label: 'op 3', disabled: false },
{ label: 'op 4', disabled: true }, { label: 'op 4', disabled: true },
], ],
max: 2,
}, },
}, },
{ {
namespace: 'basic.optionsTest', namespace: 'demo.selection',
title: 'Option Test', public: true,
desc: 'wooooo', title: 'Selection',
desc: 'Selection multiple items from options. max: {0: no limit, >0: limit}',
type: 'selection', type: 'selection',
default: [true, false, true], default: [true, false, true],
binds: { binds: {
@ -71,16 +261,10 @@ const options: Options = {
}, },
}, },
{ {
namespace: 'basic.longString', namespace: 'demo.mediaPicker',
title: 'Long string', public: true,
desc: 'A long string', title: 'Media picker',
type: 'longString', desc: '<code>type="image"|"video"|"audio?"</code>, the object must include id, id=0 for remote media.',
default: 'Opps',
},
{
namespace: 'basic.mediaPicker',
title: 'Image picker',
desc: 'Media picker',
type: 'mediaPicker', type: 'mediaPicker',
default: [ default: [
{ {
@ -101,32 +285,6 @@ const options: Options = {
}, },
], ],
}, },
social: {
title: 'Social',
icon: 'fas fa-users',
options: [
{
namespace: 'social.github',
title: 'Github username',
desc: 'Your <a href="https://github.com" target="_blank">Github</a> username',
type: 'string',
default: 'mashirozx',
},
{ namespace: 'social.weibo', title: 'Weibo username', type: 'string', default: 'mashirozx' },
],
},
other: {
title: 'Other',
icon: 'fas fa-umbrella',
options: [
{
namespace: 'other.hello',
title: 'Hello world',
type: 'string',
default: 'world',
},
],
},
} }
export default options export default options

24
src/admin/scheme.ts Normal file
View File

@ -0,0 +1,24 @@
import palette, { Scheme } from '@/utils/palette'
const config: { [key: string]: Scheme } = {
Default: {
primary: '#2271b1',
secondary: '#72aee6',
background: '#f0f0f1',
surface: '#ffffff',
error: '#d63638',
'on-primary': '#ffffff',
'on-secondary': '#ffffff',
'on-background': '#1d2327',
'on-surface': '#3c434a',
'on-error': '#ffffff',
},
}
const { name } = (window as any).AdminColors as { [key: string]: keyof typeof config }
const theConfig = config[name] ?? config['Default']
const scheme = palette(theConfig)
export default scheme

View File

@ -11,7 +11,9 @@ export interface OptionStore {
} }
export default function auth(): object { export default function auth(): object {
const [config, setConfig]: [Ref<OptionStore>, (arg: OptionStore) => void] = useState({}) const wpLocalizeScript = (window as any).SakuraOptions?.data as OptionStore
const initConfig = cloneDeep(wpLocalizeScript ?? {})
const [config, setConfig] = useState(initConfig, false)
const updateOption = (configState: Ref<OptionStore>, key: string, value: any) => { const updateOption = (configState: Ref<OptionStore>, key: string, value: any) => {
const config = cloneDeep(configState.value) const config = cloneDeep(configState.value)
@ -19,21 +21,5 @@ export default function auth(): object {
setConfig(config) setConfig(config)
} }
// const saveOption
// const resetOption
// const resetAllOption
// const mapOption = (configState: Ref<OptionStore>) => {
// const config = cloneDeep(configState.value)
// const data: OptionStore = {}
// Object.keys(options).forEach((tagKey) => {
// const tag = options[tagKey]
// Object.keys(tag.options).forEach((namespace) => {
// data[tagKey][namespace].payload = config[namespace]
// })
// })
// return data
// }
return { config, updateOption } return { config, updateOption }
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="checkbox__container"> <div :class="['checkbox__container', { disabled: $props.disabled }]">
<div class="mdc-checkbox mdc-checkbox--touch" :ref="setElRef" @change="handleChange"> <div class="mdc-checkbox mdc-checkbox--touch" :ref="setElRef" @change="handleChange">
<input type="checkbox" class="mdc-checkbox__native-control" :id="`checkbox-${id}`" /> <input type="checkbox" class="mdc-checkbox__native-control" :id="`checkbox-${id}`" />
<div class="mdc-checkbox__background"> <div class="mdc-checkbox__background">
@ -86,6 +86,12 @@ export default defineComponent({
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
&.disabled {
cursor: not-allowed;
.label {
cursor: not-allowed;
}
}
.label { .label {
user-select: none; user-select: none;
} }

View File

@ -0,0 +1,185 @@
<template>
<div class="item__container mdc-card mdc-card--outlined">
<div class="item__content">
<div class="column__wrapper--icon">
<span><i class="fas fa-info-circle"></i></span>
</div>
<div class="column__wrapper--content">
<div class="row__wrapper--title">
<div class="title__content--message">
<div class="title">
<span>{{ $props.message.title }}</span>
</div>
<div
class="detailed"
:style="{ height: shouldShowDetail ? `${expandContentHeight}px` : '0px' }"
>
<div :class="['content', { show: shouldShowDetail }]" :ref="setExpandContentRef">
<span>{{ $props.message.detail }}</span>
</div>
</div>
</div>
<div
v-if="$props.message.detail"
:class="['title__content--collapse', { reverse: shouldShowDetail }]"
:title="msg.showDetails"
@click="handleShowDetailClick"
>
<i class="fas fa-angle-double-down"></i>
</div>
<div class="title__content--close" :title="msg.close" @click="handleCloseMessageEvent">
<i class="fas fa-times-circle"></i>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue'
import { useIntl, useInjector, useState, useElementRef, useResizeObserver } from '@/hooks'
import { messages } from '@/store'
import NormalButton from '@/components/buttons/NormalButton.vue'
export default defineComponent({
components: { NormalButton },
props: { message: Object },
setup(props) {
const intl = useIntl()
const msg = {
dismiss: intl.formatMessage({
id: 'messages.popup.dismiss',
defaultMessage: 'Dismiss',
}),
close: intl.formatMessage({
id: 'messages.popup.close',
defaultMessage: 'Close',
}),
showDetails: intl.formatMessage({
id: 'messages.popup.showDetails',
defaultMessage: 'Show details',
}),
}
const { messageList, removeMessage } = useInjector(messages)
const handleCloseMessageEvent = () => {
if (props.message) removeMessage(messageList, props.message.id)
}
const [shouldShowDetail, setShouldShowDetail] = useState(false)
const handleShowDetailClick = () => {
setShouldShowDetail(!shouldShowDetail.value)
}
const [expandContentRef, setExpandContentRef] = useElementRef()
const expandContentSize = useResizeObserver(expandContentRef)
const expandContentHeight = computed(() =>
expandContentSize.value.height === NaN
? 0
: expandContentSize.value.height + expandContentSize.value.paddingTop
)
return {
msg,
handleCloseMessageEvent,
shouldShowDetail,
handleShowDetailClick,
setExpandContentRef,
expandContentHeight,
}
},
})
</script>
<style lang="scss" scoped>
@use "sass:color";
.item__container {
width: var(--width);
background: #ffffff;
border-left: 3px solid #34d058;
> .item__content {
width: calc(100% - 24px);
padding: 12px;
display: flex;
flex-flow: row nowrap;
align-items: space-between;
align-items: flex-start;
gap: 12px;
> .column__wrapper {
&--icon {
flex: 0 0 auto;
span {
color: #34d058;
font-size: medium;
}
}
&--content {
flex: 1 1 auto;
width: 100%;
display: flex;
flex-flow: column nowrap;
align-items: flex-start;
> .row__wrapper {
&--title {
width: 100%;
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
> * span {
line-height: 16px;
}
> .title__content {
&--message {
flex: 1 1 auto;
width: 100%;
> .title {
span {
color: #3c434a;
}
}
> .detailed {
transition: height 0.5s cubic-bezier(0, 0, 0.3, 1);
overflow: hidden;
> .content {
padding-top: 6px;
width: 100%;
height: auto;
transform: scaleY(0);
transform-origin: top;
transition: transform 0.5s cubic-bezier(0, 0, 0.3, 1);
&.show {
transform: scaleY(1);
}
span {
color: color.adjust(#3c434a, $lightness: 30%);
}
}
}
}
&--collapse {
flex: 0 0 auto;
transform: scaleY(1);
transition: transform 0.5s cubic-bezier(0, 0, 0.3, 1);
&.reverse {
transform: scaleY(-1);
}
}
&--close {
flex: 0 0 auto;
}
}
}
&--buttons {
align-self: flex-end;
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,111 @@
<template>
<div class="messages__container" :style="positionControl">
<transition-group name="messages" tag="div">
<div class="message__wrapper" v-for="message in messagesCalc" :key="message.id">
<MessageNormal v-if="message.style === 'normal'" :message="message"></MessageNormal>
</div>
</transition-group>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue'
import { cloneDeep } from 'lodash'
import { useInjector } from '@/hooks'
import { messages } from '@/store'
import MessageNormal from './MessageNormal.vue'
export default defineComponent({
components: { MessageNormal },
props: {
positionX: { type: String, default: 'right' }, // left center right
positionY: { type: String, default: 'top' }, // top bottom
width: { type: String, default: '380px' },
},
setup(props) {
const { messageList } = useInjector(messages)
const messagesCalc = computed(() => {
if (props.positionY === 'bottom') {
return cloneDeep(messageList.value).reverse()
} else {
return cloneDeep(messageList.value)
}
})
const positionControl = computed(() => {
return {
'--from-0': props.positionY === 'bottom' ? '100%' : '-100%',
'--from-70': props.positionY === 'bottom' ? '-20px' : '20px',
'--from-100': 0,
'--to-0': 0,
'--to-30': props.positionX === 'right' ? '-20px' : '20px',
'--to-70': props.positionX === 'right' ? '100%' : '-100%',
'--to-100': props.positionX === 'right' ? '100%' : '-100%',
'--absolute-fix': props.positionY === 'bottom' ? '-100%' : '0',
'--width': props.width,
}
})
return { messagesCalc, positionControl }
},
})
</script>
<style lang="scss" scoped>
.messages__container {
width: calc(var(--width) + 12px);
.message__wrapper {
padding: 6px;
}
.messages {
&-enter-active {
animation: from 0.5s forwards;
}
&-leave-active {
position: absolute;
transform-origin: center center;
animation: to 0.5s forwards;
}
&-move {
transition: transform 0.3s ease;
transition-delay: 0.3s;
}
}
@keyframes from {
0% {
transform: translateY(var(--from-0));
opacity: 0;
}
70% {
transform: translateY(var(--from-70));
opacity: 0.8;
}
100% {
transform: translateY(var(--from-100));
opacity: 1;
}
}
@keyframes to {
0% {
transform: translateX(var(--to-0)) translateY(var(--absolute-fix));
opacity: 1;
}
30% {
transform: translateX(var(--to-30)) translateY(var(--absolute-fix));
opacity: 0.8;
}
70% {
transform: translateX(var(--to-70)) translateY(var(--absolute-fix));
opacity: 0;
}
100% {
transform: translateX(var(--to-100)) translateY(var(--absolute-fix));
opacity: 0;
}
}
}
</style>

View File

@ -1,11 +1,11 @@
<template> <template>
<div class="radio__container"> <div :class="['radio__container', { disabled: $props.disabled }]">
<div class="mdc-radio" :ref="setElRef"> <div class="mdc-radio" :ref="setElRef">
<input <input
class="mdc-radio__native-control" class="mdc-radio__native-control"
type="checkbox" type="checkbox"
:id="`radio-${id}`" :id="id"
:name="`radio-${id}`" :name="id"
@change="handleChange" @change="handleChange"
/> />
<div class="mdc-radio__background"> <div class="mdc-radio__background">
@ -14,7 +14,7 @@
</div> </div>
<div class="mdc-radio__ripple"></div> <div class="mdc-radio__ripple"></div>
</div> </div>
<label class="label" :for="`radio-${id}`">{{ $props.label }}</label> <label class="label" :for="id">{{ $props.label }}</label>
</div> </div>
</template> </template>
@ -32,7 +32,7 @@ export default defineComponent({
}, },
emits: ['update:checked'], emits: ['update:checked'],
setup(props, { emit }) { setup(props, { emit }) {
const id = uniqueHash() const id = `radio-${uniqueHash()}`
const [elRef, setElRef] = useElementRef() const [elRef, setElRef] = useElementRef()
@ -84,6 +84,12 @@ export default defineComponent({
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
&.disabled {
cursor: not-allowed;
.label {
cursor: not-allowed;
}
}
.label { .label {
user-select: none; user-select: none;
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="switcher__container"> <div :class="['switcher__container', { disabled: $props.disabled }]">
<button <button
:id="`switch-${id}`" :id="`switch-${id}`"
class="mdc-switch mdc-switch--unselected" class="mdc-switch mdc-switch--unselected"
@ -93,11 +93,20 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use './theme';
.switcher__container { .switcher__container {
@include theme.variables;
height: 56px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
&.disabled {
cursor: not-allowed;
.label {
cursor: not-allowed;
}
}
.label { .label {
user-select: none; user-select: none;
padding-left: 10px; padding-left: 10px;

View File

@ -0,0 +1,56 @@
/**
* last modify: "@material/switch": "^12.0.0-canary.9f68a932e.0"
*/
@use 'sass:color';
@use '@material/theme/color-palette';
@use '@material/theme/theme-color';
@use '@material/theme/theme';
@use '@material/elevation/elevation-theme';
@mixin variables {
$_hairline: color-palette.$grey-300;
$_inverse-primary: var(--mdc-theme-primary-lighter-25);
// color.scale(theme-color.prop-value(primary), $lightness: 75%);
$_on-surface: color-palette.$grey-800;
$_on-surface-variant: color-palette.$grey-700;
$_on-surface-state-content: color-palette.$grey-900;
$_primary-state-content: var(--mdc-theme-primary-darker-10);
// color.scale(theme-color.prop-value(primary), $blackness: 50%);
--mdc-switch-disabled-selected-handle-color: #{$_on-surface};
--mdc-switch-disabled-selected-icon-color: var(--mdc-theme-on-primary);
--mdc-switch-disabled-selected-track-color: #{$_on-surface};
--mdc-switch-disabled-unselected-handle-color: #{$_on-surface};
--mdc-switch-disabled-unselected-icon-color: var(--mdc-theme-on-primary);
--mdc-switch-disabled-unselected-track-color: #{$_on-surface};
--mdc-switch-handle-shadow-color: #{elevation-theme.$baseline-color};
--mdc-switch-handle-surface-color: var(--mdc-theme-surface);
--mdc-switch-selected-focus-handle-color: #{$_primary-state-content};
--mdc-switch-selected-focus-state-layer-color: var(--mdc-theme-primary);
--mdc-switch-selected-focus-track-color: #{$_inverse-primary};
--mdc-switch-selected-handle-color: var(--mdc-theme-primary);
--mdc-switch-selected-hover-handle-color: #{$_primary-state-content};
--mdc-switch-selected-hover-state-layer-color: var(--mdc-theme-primary);
--mdc-switch-selected-hover-track-color: #{$_inverse-primary};
--mdc-switch-selected-icon-color: var(--mdc-theme-on-primary);
--mdc-switch-selected-pressed-handle-color: #{$_primary-state-content};
--mdc-switch-selected-pressed-state-layer-color: var(--mdc-theme-primary);
--mdc-switch-selected-pressed-track-color: #{$_inverse-primary};
--mdc-switch-selected-track-color: #{$_inverse-primary};
--mdc-switch-unselected-focus-handle-color: #{$_on-surface-state-content};
--mdc-switch-unselected-focus-state-layer-color: #{$_on-surface};
--mdc-switch-unselected-focus-track-color: #{$_hairline};
--mdc-switch-unselected-handle-color: #{$_on-surface-variant};
--mdc-switch-unselected-hover-handle-color: #{$_on-surface-state-content};
--mdc-switch-unselected-hover-state-layer-color: #{$_on-surface};
--mdc-switch-unselected-hover-track-color: #{$_hairline};
--mdc-switch-unselected-icon-color: var(--mdc-theme-on-primary);
--mdc-switch-unselected-pressed-handle-color: #{$_on-surface-state-content};
--mdc-switch-unselected-pressed-state-layer-color: #{$_on-surface};
--mdc-switch-unselected-pressed-track-color: #{$_hairline};
--mdc-switch-unselected-track-color: #{$_hairline};
// --mdc-switch-disabled-selected-icon-color: GrayText;
// --mdc-switch-disabled-unselected-icon-color: GrayText;
}

View File

@ -8,6 +8,7 @@ import useReachElementSide from './useReachElementSide'
import { useElementRef, useElementRefs } from './useElementRef' import { useElementRef, useElementRefs } from './useElementRef'
import useOffsetDistance from './useOffsetDistance' import useOffsetDistance from './useOffsetDistance'
import useMDCRipple from './mdc/useMDCRipple' import useMDCRipple from './mdc/useMDCRipple'
import useMessage from './useMessage'
export { export {
useState, useState,
@ -26,4 +27,5 @@ export {
useElementRef, useElementRef,
useElementRefs, useElementRefs,
useOffsetDistance, useOffsetDistance,
useMessage,
} }

View File

@ -2,16 +2,24 @@ import { ref, readonly, UnwrapRef, DeepReadonly, Ref } from 'vue'
import storage from '@/utils/storage' import storage from '@/utils/storage'
// TODO: correct return type?? // TODO: correct return type??
export const useState = <T>(defaultValue: T): any => { export const useState = <T>(
defaultValue: T,
shouldReadonly = true
): [Ref<UnwrapRef<T>>, (arg: T) => void] => {
const state = ref(defaultValue) const state = ref(defaultValue)
const set = (value: T): void => { const set = (value: T): void => {
state.value = value as UnwrapRef<T> state.value = value as UnwrapRef<T>
} }
const get = readonly(state) const get = (shouldReadonly ? readonly(state) : state) as Ref<UnwrapRef<T>>
return [get, set] return [get, set]
} }
export const usePersistedState = <K, T>(key: K, defaultValue: T, cachePeriod?: number): any => { export const usePersistedState = <K, T>(
key: K,
defaultValue: T,
shouldReadonly = true,
cachePeriod?: number
): [Ref<UnwrapRef<T>>, (arg: T) => void] => {
cachePeriod = cachePeriod ?? 24 * 60 * 60 cachePeriod = cachePeriod ?? 24 * 60 * 60
let state = ref(defaultValue) let state = ref(defaultValue)
@ -42,5 +50,7 @@ export const usePersistedState = <K, T>(key: K, defaultValue: T, cachePeriod?: n
if (pendingSet) set(pendingSet) if (pendingSet) set(pendingSet)
}) })
return [readonly(state), set] const get = (shouldReadonly ? readonly(state) : state) as Ref<UnwrapRef<T>>
return [get, set]
} }

20
src/hooks/useMessage.ts Normal file
View File

@ -0,0 +1,20 @@
import type { Ref } from 'vue'
import { useInjector } from '@/hooks'
import { messages } from '@/store'
import type { Message, MessageOptions } from '@/store/messages'
export default function useMessage() {
const {
messageList,
addMessage,
}: {
messageList: Ref<Message[]>
addMessage: (state: Ref<Message[]>, options: MessageOptions) => void
} = useInjector(messages)
const _addMessage = (options: MessageOptions) => {
addMessage(messageList, options)
}
return _addMessage
}

View File

@ -86,7 +86,8 @@ export default defineComponent({
components: { NavItem }, components: { NavItem },
setup() { setup() {
const avatar = 'https://view.moezx.cc/images/2021/06/13/d6b010a378d392d4633008b915f98ab1.md.png' const avatar = 'https://view.moezx.cc/images/2021/06/13/d6b010a378d392d4633008b915f98ab1.md.png'
const logo = 'https://v3.vuejs.org/logo.png' const logo =
window.InitState.config['basic.site.logo'][0]?.url || 'https://v3.vuejs.org/logo.png'
const [navBarItemRefs, setNavBarItemRefs] = useElementRefs() const [navBarItemRefs, setNavBarItemRefs] = useElementRefs()
const [navBarWrapperRef, setNavBarWrapperRef] = useElementRef() const [navBarWrapperRef, setNavBarWrapperRef] = useElementRef()

View File

@ -1,7 +1,28 @@
{ {
"messages.admin.invalidUrl": "Invalid URL",
"messages.admin.uplicateUrls": "Duplicate URLs",
"messages.popup.close": "Close",
"messages.popup.dismiss": "Dismiss",
"messages.popup.showDetails": "Show details",
"messages.wordpress.applicationPasswordsEndpointNotAvailable": "ApplicationPasswords is not avaliabe in your WordPress installation, please upgrade WordPress to v5.6.0 above, or enable the ApplicationPasswords feature in v5.6.0 above installation.", "messages.wordpress.applicationPasswordsEndpointNotAvailable": "ApplicationPasswords is not avaliabe in your WordPress installation, please upgrade WordPress to v5.6.0 above, or enable the ApplicationPasswords feature in v5.6.0 above installation.",
"messages.wordpress.permalink.shouldIncludeFieldsInPost": "WordPress pages should use %slug% as the permalink.", "messages.wordpress.permalink.shouldIncludeFieldsInPost": "WordPress pages should use %slug% as the permalink.",
"messages.wordpress.permalink.shouldIncludeFieldsInSingle": "WordPress permalink should include at least one of %post_id%, %postname%. You may set them here: {baseUrl}/wp-admin/options-permalink.php", "messages.wordpress.permalink.shouldIncludeFieldsInSingle": "WordPress permalink should include at least one of %post_id%, %postname%. You may set them here: {baseUrl}/wp-admin/options-permalink.php",
"options.basic.site.logo.binds.button": "Use this image",
"options.basic.site.logo.binds.title": "Select image for site logo.",
"options.basic.site.logo.desc": "The site's Logo image, will display on navigation bar.",
"options.basic.site.logo.title": "Site logo",
"options.basic.site.title.desc": "The site title",
"options.basic.site.title.title": "Site title",
"options.thirdParty.reCaptcha.enable.desc": "Use reCAPTCHA for anti-spam check.",
"options.thirdParty.reCaptcha.enable.negativeLabel": "Disabled",
"options.thirdParty.reCaptcha.enable.positiveLabel": "Enabled",
"options.thirdParty.reCaptcha.enable.title": "Enable reCAPTCHA",
"options.thirdParty.reCaptcha.secretKey.title": "reCAPTCHA secret key",
"options.thirdParty.reCaptcha.siteKey.title": "reCAPTCHA site key",
"options.thirdParty.reCaptcha.version.desc": "Register your reCAPTCHA app '<a href=\"https://www.google.com/recaptcha/about/\" target=\"_blank\">here</a>', and choose a version.",
"options.thirdParty.reCaptcha.version.label.v2": "reCAPTCHA version 2",
"options.thirdParty.reCaptcha.version.label.v3": "reCAPTCHA version 3",
"options.thirdParty.reCaptcha.version.title": "reCAPTCHA version",
"posts.comment.composer.authorEmail.label": "Email *", "posts.comment.composer.authorEmail.label": "Email *",
"posts.comment.composer.authorName.label": "Nickname *", "posts.comment.composer.authorName.label": "Nickname *",
"posts.comment.composer.authorUrl.label": "Link", "posts.comment.composer.authorUrl.label": "Link",

View File

@ -1,4 +1,19 @@
{ {
"messages.admin.invalidUrl": {
"defaultMessage": "Invalid URL"
},
"messages.admin.uplicateUrls": {
"defaultMessage": "Duplicate URLs"
},
"messages.popup.close": {
"defaultMessage": "Close"
},
"messages.popup.dismiss": {
"defaultMessage": "Dismiss"
},
"messages.popup.showDetails": {
"defaultMessage": "Show details"
},
"messages.wordpress.applicationPasswordsEndpointNotAvailable": { "messages.wordpress.applicationPasswordsEndpointNotAvailable": {
"defaultMessage": "ApplicationPasswords is not avaliabe in your WordPress installation, please upgrade WordPress to v5.6.0 above, or enable the ApplicationPasswords feature in v5.6.0 above installation." "defaultMessage": "ApplicationPasswords is not avaliabe in your WordPress installation, please upgrade WordPress to v5.6.0 above, or enable the ApplicationPasswords feature in v5.6.0 above installation."
}, },
@ -8,6 +23,54 @@
"messages.wordpress.permalink.shouldIncludeFieldsInSingle": { "messages.wordpress.permalink.shouldIncludeFieldsInSingle": {
"defaultMessage": "WordPress permalink should include at least one of %post_id%, %postname%. You may set them here: {baseUrl}/wp-admin/options-permalink.php" "defaultMessage": "WordPress permalink should include at least one of %post_id%, %postname%. You may set them here: {baseUrl}/wp-admin/options-permalink.php"
}, },
"options.basic.site.logo.binds.button": {
"defaultMessage": "Use this image"
},
"options.basic.site.logo.binds.title": {
"defaultMessage": "Select image for site logo."
},
"options.basic.site.logo.desc": {
"defaultMessage": "The site's Logo image, will display on navigation bar."
},
"options.basic.site.logo.title": {
"defaultMessage": "Site logo"
},
"options.basic.site.title.desc": {
"defaultMessage": "The site title"
},
"options.basic.site.title.title": {
"defaultMessage": "Site title"
},
"options.thirdParty.reCaptcha.enable.desc": {
"defaultMessage": "Use reCAPTCHA for anti-spam check."
},
"options.thirdParty.reCaptcha.enable.negativeLabel": {
"defaultMessage": "Disabled"
},
"options.thirdParty.reCaptcha.enable.positiveLabel": {
"defaultMessage": "Enabled"
},
"options.thirdParty.reCaptcha.enable.title": {
"defaultMessage": "Enable reCAPTCHA"
},
"options.thirdParty.reCaptcha.secretKey.title": {
"defaultMessage": "reCAPTCHA secret key"
},
"options.thirdParty.reCaptcha.siteKey.title": {
"defaultMessage": "reCAPTCHA site key"
},
"options.thirdParty.reCaptcha.version.desc": {
"defaultMessage": "Register your reCAPTCHA app '<a href=\"https://www.google.com/recaptcha/about/\" target=\"_blank\">here</a>', and choose a version."
},
"options.thirdParty.reCaptcha.version.label.v2": {
"defaultMessage": "reCAPTCHA version 2"
},
"options.thirdParty.reCaptcha.version.label.v3": {
"defaultMessage": "reCAPTCHA version 3"
},
"options.thirdParty.reCaptcha.version.title": {
"defaultMessage": "reCAPTCHA version"
},
"posts.comment.composer.authorEmail.label": { "posts.comment.composer.authorEmail.label": {
"defaultMessage": "Email *" "defaultMessage": "Email *"
}, },

View File

@ -4,7 +4,7 @@ import '@yzfe/svgicon/lib/svgicon.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { storeProviderPlugin } from './hooks/store' import { storeProviderPlugin } from './hooks/store'
import { auth, init, posts, comments } from './store' import { auth, init, posts, comments, messages } from './store'
import { intlPlugin } from './locales' import { intlPlugin } from './locales'
import UiIcon from '@/components/icon/UiIcon.vue' import UiIcon from '@/components/icon/UiIcon.vue'
import Image from '@/components/image/Image.vue' import Image from '@/components/image/Image.vue'
@ -13,7 +13,7 @@ const theWindow = window as any
theWindow.router = router theWindow.router = router
const app = createApp(App) const app = createApp(App)
app.use(storeProviderPlugin, [auth, init, posts, comments]) app.use(storeProviderPlugin, [auth, init, posts, comments, messages])
app.use(router) app.use(router)
app.use(intlPlugin) app.use(intlPlugin)
app.use(VueSvgIconPlugin, { tagName: 'svg-icon' }) app.use(VueSvgIconPlugin, { tagName: 'svg-icon' })

View File

@ -2,5 +2,6 @@ import auth from './auth'
import init from './init' import init from './init'
import posts from './posts' import posts from './posts'
import comments from './comments' import comments from './comments'
import messages from './messages'
export { auth, init, posts, comments } export { auth, init, posts, comments, messages }

55
src/store/messages.ts Normal file
View File

@ -0,0 +1,55 @@
import { Ref } from 'vue'
import { cloneDeep, remove } from 'lodash'
import { useState } from '@/hooks'
import uniqueHash from '@/utils/uniqueHash'
export interface Message {
id: string
title: string
detail?: string
type?: 'success' | 'warning' | 'info' | 'error'
style?: 'normal' | 'collapse'
options?: { [key: string]: any }
closeTimeout?: number
}
export interface MessageOptions extends Omit<Message, 'id'> {}
export default function msg(): object {
const [messageList, setMessageList]: [Ref<Message[]>, (arg: Message[]) => void] = useState([])
const addMessage = (state: typeof messageList, options: MessageOptions) => {
const id = `message_${uniqueHash()}`
const _state = cloneDeep(state.value)
const message = { ...options, id }
message['type'] ||= 'info' // the default message type
message['style'] ||= 'normal' // the default message type
_state.push(message)
setMessageList(_state)
if (options.closeTimeout !== undefined && options.closeTimeout <= 0) {
return
}
const closeTimeout = options.closeTimeout || 3000
setTimeout(() => removeMessage(state, id), closeTimeout)
}
const removeMessage = (state: typeof messageList, id: string) => {
const _state = cloneDeep(state.value)
remove(_state, (item) => item.id === id)
setMessageList(_state)
}
const clearMessage = () => {
setMessageList([])
}
return {
messageList,
addMessage,
removeMessage,
clearMessage,
}
}

39
src/utils/palette.ts Normal file
View File

@ -0,0 +1,39 @@
import { cloneDeep } from 'lodash'
import chroma from 'chroma-js'
export interface Scheme {
// base
primary: string
secondary: string
background: string
surface: string
error: string
// text color of a * background
'on-primary': string
'on-secondary': string
'on-background': string
'on-surface': string
'on-error': string
// modifier
'primary-lighter-25'?: string
'primary-darker-10'?: string
}
/**
* @param scheme
* @returns CSS variables object using directly in :style
*/
export default function (scheme: Scheme) {
const modifier = {
'primary-lighter-25': chroma(scheme.primary).brighten(2.5).hex(),
'primary-darker-10': chroma(scheme.primary).darken(1).hex(),
}
const _scheme = cloneDeep(Object.assign(scheme, modifier))
const colors: { [key: string]: string } = {}
Object.keys(scheme).forEach(
(key) => (colors[`--mdc-theme-${key}`] = _scheme[key as keyof typeof scheme])
)
return colors
}

View File

@ -1290,6 +1290,11 @@
dependencies: dependencies:
"@babel/types" "^7.3.0" "@babel/types" "^7.3.0"
"@types/chroma-js@^2.1.3":
version "2.1.3"
resolved "https://registry.nlark.com/@types/chroma-js/download/@types/chroma-js-2.1.3.tgz#0b03d737ff28fad10eb884e0c6cedd5ffdc4ba0a"
integrity sha1-CwPXN/8o+tEOuITgxs7dX/3Eugo=
"@types/crypto-js@^4.0.1": "@types/crypto-js@^4.0.1":
version "4.0.1" version "4.0.1"
resolved "https://registry.nlark.com/@types/crypto-js/download/@types/crypto-js-4.0.1.tgz#3a4bd24518b0e6c5940da4e2659eeb2ef0806963" resolved "https://registry.nlark.com/@types/crypto-js/download/@types/crypto-js-4.0.1.tgz#3a4bd24518b0e6c5940da4e2659eeb2ef0806963"
@ -2271,6 +2276,13 @@ character-parser@^2.2.0:
optionalDependencies: optionalDependencies:
fsevents "~2.3.2" fsevents "~2.3.2"
chroma-js@^2.1.2:
version "2.1.2"
resolved "https://registry.nlark.com/chroma-js/download/chroma-js-2.1.2.tgz#1075cb9ae25bcb2017c109394168b5cf3aa500ec"
integrity sha1-EHXLmuJbyyAXwQk5QWi1zzqlAOw=
dependencies:
cross-env "^6.0.3"
ci-info@^2.0.0: ci-info@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.nlark.com/ci-info/download/ci-info-2.0.0.tgz?cache=0&sync_timestamp=1622039942508&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fci-info%2Fdownload%2Fci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" resolved "https://registry.nlark.com/ci-info/download/ci-info-2.0.0.tgz?cache=0&sync_timestamp=1622039942508&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fci-info%2Fdownload%2Fci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
@ -2399,11 +2411,6 @@ commander@8:
resolved "https://registry.yarnpkg.com/commander/-/commander-8.0.0.tgz#1da2139548caef59bd23e66d18908dfb54b02258" resolved "https://registry.yarnpkg.com/commander/-/commander-8.0.0.tgz#1da2139548caef59bd23e66d18908dfb54b02258"
integrity sha512-Xvf85aAtu6v22+E5hfVoLHqyul/jyxh91zvqk/ioJTQuJR7Z78n7H558vMPKanPSRgIEeZemT92I2g9Y8LPbSQ== integrity sha512-Xvf85aAtu6v22+E5hfVoLHqyul/jyxh91zvqk/ioJTQuJR7Z78n7H558vMPKanPSRgIEeZemT92I2g9Y8LPbSQ==
commander@^2.15.1:
version "2.20.3"
resolved "https://registry.nlark.com/commander/download/commander-2.20.3.tgz?cache=0&sync_timestamp=1621726578455&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fcommander%2Fdownload%2Fcommander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha1-/UhehMA+tIgcIHIrpIA16FMa6zM=
concat-map@0.0.1: concat-map@0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.npm.taobao.org/concat-map/download/concat-map-0.0.1.tgz" resolved "https://registry.npm.taobao.org/concat-map/download/concat-map-0.0.1.tgz"
@ -2475,6 +2482,13 @@ core-util-is@~1.0.0:
resolved "https://registry.npm.taobao.org/core-util-is/download/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" resolved "https://registry.npm.taobao.org/core-util-is/download/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
cross-env@^6.0.3:
version "6.0.3"
resolved "https://registry.npm.taobao.org/cross-env/download/cross-env-6.0.3.tgz#4256b71e49b3a40637a0ce70768a6ef5c72ae941"
integrity sha1-Qla3HkmzpAY3oM5wdopu9ccq6UE=
dependencies:
cross-spawn "^7.0.0"
cross-spawn@^5.0.1: cross-spawn@^5.0.1:
version "5.1.0" version "5.1.0"
resolved "https://registry.npm.taobao.org/cross-spawn/download/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" resolved "https://registry.npm.taobao.org/cross-spawn/download/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
@ -2484,7 +2498,7 @@ cross-spawn@^5.0.1:
shebang-command "^1.2.0" shebang-command "^1.2.0"
which "^1.2.9" which "^1.2.9"
cross-spawn@^7.0.2, cross-spawn@^7.0.3: cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3" version "7.0.3"
resolved "https://registry.npm.taobao.org/cross-spawn/download/cross-spawn-7.0.3.tgz" resolved "https://registry.npm.taobao.org/cross-spawn/download/cross-spawn-7.0.3.tgz"
integrity sha1-9zqFudXUHQRVUcF34ogtSshXKKY= integrity sha1-9zqFudXUHQRVUcF34ogtSshXKKY=
@ -3166,11 +3180,6 @@ esutils@^2.0.2:
resolved "https://registry.npm.taobao.org/esutils/download/esutils-2.0.3.tgz" resolved "https://registry.npm.taobao.org/esutils/download/esutils-2.0.3.tgz"
integrity sha1-dNLrTeC42hKTcRkQ1Qd1ubcQ72Q= integrity sha1-dNLrTeC42hKTcRkQ1Qd1ubcQ72Q=
eventemitter3@^4.0.0:
version "4.0.7"
resolved "https://registry.nlark.com/eventemitter3/download/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
integrity sha1-Lem2j2Uo1WRO9cWVJqG0oHMGFp8=
execa@^0.7.0: execa@^0.7.0:
version "0.7.0" version "0.7.0"
resolved "https://registry.nlark.com/execa/download/execa-0.7.0.tgz?cache=0&sync_timestamp=1622825396605&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fexeca%2Fdownload%2Fexeca-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" resolved "https://registry.nlark.com/execa/download/execa-0.7.0.tgz?cache=0&sync_timestamp=1622825396605&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fexeca%2Fdownload%2Fexeca-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
@ -3328,21 +3337,11 @@ fn-name@^2.0.1:
resolved "https://registry.npm.taobao.org/fn-name/download/fn-name-2.0.1.tgz#5214d7537a4d06a4a301c0cc262feb84188002e7" resolved "https://registry.npm.taobao.org/fn-name/download/fn-name-2.0.1.tgz#5214d7537a4d06a4a301c0cc262feb84188002e7"
integrity sha1-UhTXU3pNBqSjAcDMJi/rhBiAAuc= integrity sha1-UhTXU3pNBqSjAcDMJi/rhBiAAuc=
follow-redirects@^1.0.0, follow-redirects@^1.10.0: follow-redirects@^1.10.0:
version "1.14.1" version "1.14.1"
resolved "https://registry.nlark.com/follow-redirects/download/follow-redirects-1.14.1.tgz?cache=0&sync_timestamp=1620555300559&other_urls=https%3A%2F%2Fregistry.nlark.com%2Ffollow-redirects%2Fdownload%2Ffollow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43" resolved "https://registry.nlark.com/follow-redirects/download/follow-redirects-1.14.1.tgz?cache=0&sync_timestamp=1620555300559&other_urls=https%3A%2F%2Fregistry.nlark.com%2Ffollow-redirects%2Fdownload%2Ffollow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
integrity sha1-2RFN7Qoc/dM04WTmZirQK/2R/0M= integrity sha1-2RFN7Qoc/dM04WTmZirQK/2R/0M=
foreman@^3.0.1:
version "3.0.1"
resolved "https://registry.nlark.com/foreman/download/foreman-3.0.1.tgz#805f28afc5a4bbaf08dbb1f5018c557dcbb8a410"
integrity sha1-gF8or8Wku68I27H1AYxVfcu4pBA=
dependencies:
commander "^2.15.1"
http-proxy "^1.17.0"
mustache "^2.2.1"
shell-quote "^1.6.1"
form-data@^3.0.0: form-data@^3.0.0:
version "3.0.1" version "3.0.1"
resolved "https://registry.npm.taobao.org/form-data/download/form-data-3.0.1.tgz?cache=0&sync_timestamp=1613410812604&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fform-data%2Fdownload%2Fform-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" resolved "https://registry.npm.taobao.org/form-data/download/form-data-3.0.1.tgz?cache=0&sync_timestamp=1613410812604&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fform-data%2Fdownload%2Fform-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f"
@ -3677,15 +3676,6 @@ http-proxy-agent@^4.0.1:
agent-base "6" agent-base "6"
debug "4" debug "4"
http-proxy@^1.17.0:
version "1.18.1"
resolved "https://registry.npm.taobao.org/http-proxy/download/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
integrity sha1-QBVB8FNIhLv5UmAzTnL4juOXZUk=
dependencies:
eventemitter3 "^4.0.0"
follow-redirects "^1.0.0"
requires-port "^1.0.0"
https-proxy-agent@^2.2.4: https-proxy-agent@^2.2.4:
version "2.2.4" version "2.2.4"
resolved "https://registry.npm.taobao.org/https-proxy-agent/download/https-proxy-agent-2.2.4.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhttps-proxy-agent%2Fdownload%2Fhttps-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b" resolved "https://registry.npm.taobao.org/https-proxy-agent/download/https-proxy-agent-2.2.4.tgz?cache=0&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fhttps-proxy-agent%2Fdownload%2Fhttps-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b"
@ -5039,11 +5029,6 @@ ms@^2.1.1:
resolved "https://registry.npm.taobao.org/ms/download/ms-2.1.3.tgz?cache=0&sync_timestamp=1607433872491&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fms%2Fdownload%2Fms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" resolved "https://registry.npm.taobao.org/ms/download/ms-2.1.3.tgz?cache=0&sync_timestamp=1607433872491&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fms%2Fdownload%2Fms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha1-V0yBOM4dK1hh8LRFedut1gxmFbI= integrity sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=
mustache@^2.2.1:
version "2.3.2"
resolved "https://registry.npm.taobao.org/mustache/download/mustache-2.3.2.tgz?cache=0&sync_timestamp=1616959918003&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fmustache%2Fdownload%2Fmustache-2.3.2.tgz#a6d4d9c3f91d13359ab889a812954f9230a3d0c5"
integrity sha1-ptTZw/kdEzWauImoEpVPkjCj0MU=
nanoid@^3.1.23: nanoid@^3.1.23:
version "3.1.23" version "3.1.23"
resolved "https://registry.nlark.com/nanoid/download/nanoid-3.1.23.tgz" resolved "https://registry.nlark.com/nanoid/download/nanoid-3.1.23.tgz"
@ -5893,11 +5878,6 @@ require-from-string@^2.0.2:
resolved "https://registry.npm.taobao.org/require-from-string/download/require-from-string-2.0.2.tgz" resolved "https://registry.npm.taobao.org/require-from-string/download/require-from-string-2.0.2.tgz"
integrity sha1-iaf92TgmEmcxjq/hT5wy5ZjDaQk= integrity sha1-iaf92TgmEmcxjq/hT5wy5ZjDaQk=
requires-port@^1.0.0:
version "1.0.0"
resolved "https://registry.npm.taobao.org/requires-port/download/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
resize-observer-polyfill@^1.5.1: resize-observer-polyfill@^1.5.1:
version "1.5.1" version "1.5.1"
resolved "https://registry.npm.taobao.org/resize-observer-polyfill/download/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" resolved "https://registry.npm.taobao.org/resize-observer-polyfill/download/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
@ -6071,11 +6051,6 @@ shebang-regex@^3.0.0:
resolved "https://registry.npm.taobao.org/shebang-regex/download/shebang-regex-3.0.0.tgz" resolved "https://registry.npm.taobao.org/shebang-regex/download/shebang-regex-3.0.0.tgz"
integrity sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI= integrity sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI=
shell-quote@^1.6.1:
version "1.7.2"
resolved "https://registry.nlark.com/shell-quote/download/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2"
integrity sha1-Z6fQLHbJ2iT5nSCAj8re0ODgS+I=
shellsubstitute@^1.1.0: shellsubstitute@^1.1.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.npm.taobao.org/shellsubstitute/download/shellsubstitute-1.2.0.tgz#e4f702a50c518b0f6fe98451890d705af29b6b70" resolved "https://registry.npm.taobao.org/shellsubstitute/download/shellsubstitute-1.2.0.tgz#e4f702a50c518b0f6fe98451890d705af29b6b70"