Added svg icons, currently only for items

This commit is contained in:
Stefan Zermatten
2020-05-30 18:04:48 +02:00
parent 8138cd98f1
commit 060c44f384
19 changed files with 897 additions and 590 deletions

View File

@@ -17,6 +17,7 @@ import {
renewDocIds
} from '/imports/api/parenting/parenting.js';
import {setDocToLastOrder} from '/imports/api/parenting/order.js';
import { storedIconsSchema } from '/imports/api/icons/Icons.js';
let CreatureProperties = new Mongo.Collection('creatureProperties');
@@ -36,6 +37,10 @@ let CreaturePropertySchema = new SimpleSchema({
type: Boolean,
optional: true,
},
icon: {
type: storedIconsSchema,
optional: true,
}
});
for (let key in propertySchemasIndex){

View File

@@ -1,8 +1,10 @@
import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { assertAdmin } from '/imports/api/sharing/sharingPermissions.js';
let Icons = new Mongo.Collection('icons');
iconsSchema = new SimpleSchema({
let iconsSchema = new SimpleSchema({
name: {
type: String,
unique: true,
@@ -33,21 +35,57 @@ if (Meteor.isServer) {
});
}
const storedIconsSchema = new SimpleSchema({
name: {
type: String,
},
shape: {
type: String,
},
});
Icons.attachSchema(iconsSchema);
/*
console.warn("Write Icons is not secure, disable before deployment")
// This method does not validate icons against the schema, use wisely;
const writeIcons = new ValidatedMethod({
name: 'writeIcons',
name: 'icons.methods.write',
validate: null,
run(icons){
assertAdmin(this.userId);
if (Meteor.isServer){
this.unblock();
Icons.rawCollection().insert(icons, {ordered: false});
}
}
});
*/
export { writeIcons };
const findIcons = new ValidatedMethod({
name: 'icons.methods.find',
validate: new SimpleSchema({
search: {
type: String,
max: 30,
optional: true,
},
}).validator(),
run({search}){
if (!search) return [];
if (!Meteor.isServer) return;
return Icons.find(
{ $text: {$search: search} },
{
// relevant documents have a higher score.
fields: {
score: { $meta: 'textScore' }
},
// `score` property specified in the projection fields above.
sort: {
score: { $meta: 'textScore' }
}
}
).fetch();
}
})
export { writeIcons, findIcons, storedIconsSchema };
export default Icons;

View File

@@ -113,3 +113,17 @@ export function assertDocViewPermission(doc, userId){
let root = getRoot(doc);
assertViewPermission(root, userId);
}
export function assertAdmin(userId){
assertIdValid(userId);
let user = Meteor.users.findOne(userId, {fields: {roles: 1}});
if (!user){
throw new Meteor.Error('Permission denied',
'UserId does not match any existing user');
}
let isAdmin = user.roles && user.roles.includes('admin')
if (!isAdmin){
throw new Meteor.Error('Permission denied',
'User does not have the admin role');
}
}

View File

@@ -0,0 +1,120 @@
<template lang="html">
<v-menu
v-model="menu"
:close-on-content-click="false"
lazy
transition="slide-y-transition"
min-width="290px"
style="overflow-y: auto;"
>
<template #activator="{ on }">
<div class="layout row align-center">
<v-label>{{ label }}</v-label>
<v-btn
:loading="loading"
large
icon
v-on="on"
>
<svg-icon
v-if="safeValue && safeValue.shape"
large
:shape="safeValue.shape"
/>
<v-icon
v-else
large
>
highlight_alt
</v-icon>
</v-btn>
</div>
</template>
<v-card>
<v-card-text>
<div class="layout row">
<text-field
ref="iconSearchField"
label="Search icons"
append-icon="search"
clearable
:value="searchString"
@change="search"
/>
<v-btn
icon
@click="select()"
>
<v-icon>
cancel
</v-icon>
</v-btn>
</div>
<v-layout
row
wrap
style="max-height: 400px; overflow-y: auto;"
>
<v-scale-transition
group
hide-on-leave
>
<v-btn
v-for="icon in icons"
:key="icon._id"
icon
large
@click="select(icon)"
>
<svg-icon
:shape="icon.shape"
x-large
/>
</v-btn>
</v-scale-transition>
</v-layout>
</v-card-text>
</v-card>
</v-menu>
</template>
<script>
import SvgIcon from '/imports/ui/components/global/SvgIcon.vue';
import SmartInput from '/imports/ui/components/global/SmartInputMixin.js';
import { findIcons } from '/imports/api/icons/Icons.js';
export default {
components: {
SvgIcon,
},
mixins: [SmartInput],
props: {
label: {
type: String,
default: 'Icon',
},
},
data(){return {
menu: false,
searchString: '',
icons: [],
};},
methods: {
search(value, ack){
this.searchString = value;
this.icons = [];
findIcons.call({search: value}, (error, result) => {
ack(error);
this.icons = result;
});
},
select(icon){
this.menu = false;
this.change(icon);
},
},
}
</script>
<style lang="css" scoped>
</style>

View File

@@ -23,7 +23,7 @@ export default {
inputValue: this.value,
};},
props: {
value: [String, Number, Date, Array, Boolean],
value: [String, Number, Date, Array, Object, Boolean],
errorMessages: [String, Array],
disabled: Boolean,
},

View File

@@ -0,0 +1,89 @@
<template lang="html">
<i
aria-hidden
role="img"
class="v-icon"
:class="themeClasses"
:style="color && `color: ${color}`"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
:style="`height: ${size}; width: ${size}`"
>
<path
:d="shape"
/>
</svg>
</i>
</template>
<script>
const SIZE_MAP = {
xSmall: '12px',
small: '16px',
default: '24px',
medium: '28px',
large: '36px',
xLarge: '40px',
}
export default {
inject: {
theme: {
default: {
isDark: false,
},
},
},
props: {
shape: {
type: String,
default: '',
},
color: {
type: String,
default: undefined,
},
xSmall: Boolean,
small: Boolean,
medium: Boolean,
large: Boolean,
xLarge: Boolean,
},
computed: {
isDark () {
if (this.dark === true) {
// explicitly dark
return true
} else if (this.light === true) {
// explicitly light
return false
} else {
// inherit from parent, or default false if there is none
return this.theme.isDark
}
},
themeClasses() {
return {
'theme--dark': this.isDark,
'theme--light': !this.isDark,
}
},
size() {
if (this.xSmall) return SIZE_MAP['xSmall'];
if (this.small) return SIZE_MAP['small'];
if (this.medium) return SIZE_MAP['medium'];
if (this.large) return SIZE_MAP['large'];
if (this.xLarge) return SIZE_MAP['xLarge'];
return SIZE_MAP['default'];
},
}
}
</script>
<style lang="css" scoped>
svg {
color: inherit;
fill: currentColor;
}
</style>

View File

@@ -1,17 +1,21 @@
import Vue from 'vue';
// Global components
import DatePicker from '/imports/ui/components/global/DatePicker.vue';
import IconPicker from '/imports/ui/components/global/IconPicker.vue';
import TextField from '/imports/ui/components/global/TextField.vue';
import TextArea from '/imports/ui/components/global/TextArea.vue';
import SmartSelect from '/imports/ui/components/global/SmartSelect.vue';
import SmartCombobox from '/imports/ui/components/global/SmartCombobox.vue';
import SmartCheckbox from '/imports/ui/components/global/SmartCheckbox.vue';
import SmartSwitch from '/imports/ui/components/global/SmartSwitch.vue';
import SvgIcon from '/imports/ui/components/global/SvgIcon.vue';
Vue.component('DatePicker', DatePicker);
Vue.component('IconPicker', IconPicker);
Vue.component('TextField', TextField);
Vue.component('TextArea', TextArea);
Vue.component('SmartSelect', SmartSelect);
Vue.component('SmartCombobox', SmartCombobox);
Vue.component('SmartCheckbox', SmartCheckbox);
Vue.component('SmartSwitch', SmartSwitch);
Vue.component('SvgIcon', SvgIcon);

View File

@@ -5,7 +5,7 @@
:flat="flat"
>
<property-icon
:type="model && model.type"
:model="model"
class="mr-2"
/>
<v-toolbar-title v-if="model">

View File

@@ -8,47 +8,17 @@
align-center
>
<upload-btn
:file-changed-callback="fileChanged"
title="Metadata JSON"
@file-update="metadataFileChanged"
/>
<v-text-field
ref="iconSearchField"
label="Search"
append-icon="search"
@click:append="updateSearchString"
@keydown.enter="updateSearchString"
<upload-btn
title="Sprite JSON"
@file-update="fileChanged"
/>
<icon-picker
:value="testIcon"
@change="testIconChange"
/>
<v-container
grid-list-md
fill-height
>
<v-layout
row
wrap
>
<v-flex
v-for="icon in icons"
:key="icon._id._str || icon._id"
xs3
md2
xl1
>
<v-card>
<v-card-title class="title">
{{ icon.name }}
</v-card-title>
<v-card-text>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
><path
fill="#000"
:d="icon.shape"
/></svg>
</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
</v-layout>
</v-card-text>
</v-card>
@@ -57,29 +27,31 @@
</template>
<script>
import importIcons from '/imports/ui/icons/importIcons.js';
import Icons from '/imports/api/icons/Icons.js';
import {importIcons, importIconMetadata} from '/imports/ui/icons/importIcons.js';
import IconPicker from '/imports/ui/components/global/IconPicker.vue';
import UploadButton from 'vuetify-upload-button';
export default {
components: {
IconPicker,
UploadBtn: UploadButton,
},
data(){ return {
searchString: '',
testIcon: undefined,
}},
methods: {
fileChanged (file) {
importIcons(file);
},
updateSearchString(){
this.searchString = this.$refs.iconSearchField.internalValue;
metadataFileChanged(file){
importIconMetadata(file);
},
},
meteor: {
$subscribe: {
searchIcons() {
return [this.searchString];
},
},
icons(){
return Icons.find({}, { sort: [['score', 'desc']] });
testIconChange(value, ack){
setTimeout(() => {
this.testIcon = value;
ack();
}, 1000);
},
},
};

View File

@@ -1,31 +1,50 @@
import { writeIcons } from '/imports/api/icons/Icons.js';
/*
* Import a SVG sprite file. All the icons must contain one id and one path with a
* single 'd' attribute.
* Import a SVG sprite file.
*
* A svg sprite file can be created by downloading the entire archive of
* https://game-icons.net/ then using the search function with *.svg to copy
* all the individual files into a single directory, and then using the npm
* sprite-generator to run `svg-sprite-generate -d icons -o sprite.svg` to save
* the sprite file.
* all the individual files into a single directory, and then using
* `npm i -g svg-sprite-generator` `npm i -g xml-js`
* run `svg-sprite-generate -d icons -o sprite.xml`
* run `xml-js sprite.xml --out sprite.json --compact true `
* to save the sprite file as json.
*/
let metadata;
export default function importIcons(file){
let id, d, icons = [];
export function importIcons(file){
let reader = new FileReader();
if (! metadata) throw 'No metadata to build with';
reader.onload = function(){
reader.result.match(/i?d="([^"])+"/gi).forEach(s => {
if (s[0] === 'i'){
id = s.slice(4, -1);
} else if (s[0] === 'd'){
d = s.slice(3, -1);
icons.push ({_id: Random.id(), name: id, shape: d});
}
let data = JSON.parse(reader.result);
let icons = [];
data.svg.symbol.forEach(iconData => {
let name = iconData._attributes.id;
let shape = iconData.path[1]._attributes.d;
let icon = metadata[name] || {};
icon._id = Random.id();
icon.name = name;
icon.shape = shape;
icons.push(icon);
});
writeIcons.call(icons);
};
reader.readAsText(file);
};
}
// Get metadata here:
// https://gist.github.com/ThaumRystra/ffb264dea8c32e15de95f775596194a4
// It is probably out of date though
export function importIconMetadata(file){
let reader = new FileReader();
reader.onload = function(){
metadata = JSON.parse(reader.result);
console.log(metadata);
};
reader.readAsText(file);
}

View File

@@ -41,7 +41,7 @@
flat
>
<property-icon
:type="selectedNode && selectedNode.type"
:model="selectedNode"
class="mr-2"
/>
<div class="title">

View File

@@ -1,13 +1,22 @@
<template lang="html">
<div class="item-form">
<div class="layout column align-center">
<smart-switch
label="Equipped"
class="no-flex"
:value="model.equipped"
:error-messages="errors.equipped"
@change="change('equipped', ...arguments)"
/>
<div class="layout row justify-space-around">
<div>
<icon-picker
label="Icon"
:value="model.icon"
:error-messages="errors.icon"
@change="change('icon', ...arguments)"
/>
</div>
<div>
<smart-switch
label="Equipped"
:value="model.equipped"
:error-messages="errors.equipped"
@change="change('equipped', ...arguments)"
/>
</div>
</div>
<div class="layout row wrap">
<text-field
@@ -66,12 +75,14 @@
name="Advanced"
standalone
>
<!--
<smart-switch
label="Show increment buttons"
:value="model.showIncrement"
:error-messages="errors.showIncrement"
@change="change('showIncrement', ...arguments)"
/>
-->
<smart-combobox
label="Tags"
class="mr-2"

View File

@@ -1,5 +1,13 @@
<template lang="html">
<v-icon :color="color">
<svg-icon
v-if="model.icon"
:shape="model.icon.shape"
:color="color"
/>
<v-icon
v-else
:color="color"
>
{{ icon }}
</v-icon>
</template>
@@ -9,12 +17,18 @@ import { getPropertyIcon } from '/imports/constants/PROPERTIES.js';
export default {
props: {
type: String,
color: String,
model: {
type: Object,
default: () => ({}),
},
color: {
type: String,
default: undefined,
},
},
computed: {
icon(){
return getPropertyIcon(this.type);
return getPropertyIcon(this.model && this.model.type);
},
},
}

View File

@@ -2,7 +2,7 @@
<div class="layout row align-center justify-start">
<property-icon
class="mr-2"
:type="model.type"
:model="model"
:class="selected && 'primary--text'"
:color="model.color"
/>

View File

@@ -2,7 +2,7 @@
<div class="layout row align-center justify-start">
<property-icon
class="mr-2"
:type="model.type"
:model="model"
:color="model.color"
:class="selected && 'primary--text'"
/>

View File

@@ -1,11 +1,18 @@
<template lang="html">
<div class="layout row align-center justify-start">
<property-icon
class="mr-2"
:model="model"
:color="model.color"
:class="selected && 'primary--text'"
/>
<v-icon
v-if="model.equipped"
class="mr-2"
:class="selected && 'primary--text'"
:color="model.color"
small
>
{{ model.equipped ? 'check_box' : 'check_box_outline_blank' }}
pan_tool
</v-icon>
<div
class="text-no-wrap text-truncate"

View File

@@ -14,7 +14,8 @@ import CharacterSheetToolbarItems from '/imports/ui/creature/character/Character
import CharacterSheetToolbarExtension from '/imports/ui/creature/character/CharacterSheetToolbarExtension.vue';
import SignIn from '/imports/ui/pages/SignIn.vue' ;
import Register from '/imports/ui/pages/Register.vue';
import Friends from '/imports/ui/pages/Friends.vue' ;
import IconAdmin from '/imports/ui/icons/IconAdmin.vue';
//import Friends from '/imports/ui/pages/Friends.vue' ;
import Feedback from '/imports/ui/pages/Feedback.vue' ;
import Account from '/imports/ui/pages/Account.vue' ;
import InviteSuccess from '/imports/ui/pages/InviteSuccess.vue' ;
@@ -48,6 +49,24 @@ function ensureLoggedIn(to, from, next){
});
}
function ensureAdmin(to, from, next){
Tracker.autorun((computation) => {
if (userSubscription.ready()){
computation.stop();
const user = Meteor.user();
if (user){
if (user.roles && user.roles.includes('admin')){
next()
} else {
next({name: 'home'});
}
} else {
next({ name: 'signIn', query: { redirect: to.path} });
}
}
});
}
function claimInvite(to, from, next){
Tracker.autorun((computation) => {
if (userSubscription.ready()){
@@ -222,19 +241,13 @@ RouterFactory.configure(factory => {
meta: {
title: 'Patreon Tier Too Low',
},
},{
path: '/icon-admin',
name: 'iconAdmin',
component: IconAdmin,
beforeEnter: ensureAdmin,
},
]);
// Icon admin routes
if (Meteor.isDevelopment){
let IconAdmin = require('/imports/ui/icons/IconAdmin.vue').default;
factory.addRoutes([
{
path: '/icon-admin',
name: 'iconAdmin',
component: IconAdmin,
},
]);
}
});
// Not found route has lowest priority

971
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,7 @@
"vue-router": "^3.1.6",
"vuedraggable": "^2.23.2",
"vuetify": "^1.5.24",
"vuetify-upload-button": "^1.2.2",
"vuetify-upload-button": "^2.0.2",
"vuex": "^3.1.3"
},
"devDependencies": {