Organised components into directories

This commit is contained in:
Alicia Sykes
2021-04-13 12:36:31 +01:00
parent 0761e4d5a4
commit 8bdf59a1ee
16 changed files with 17 additions and 305 deletions

View File

@@ -0,0 +1,197 @@
<template>
<div :class="`collapsable ${checkSpanNum(cols, 'col')} ${checkSpanNum(rows, 'row')}`"
:style="`${color ? 'background: '+color : ''}; ${sanitizeCustomStyles(customStyles)};`"
>
<input
:id="`collapsible-${uniqueKey}`"
class="toggle"
type="checkbox"
:checked="getCollapseState()"
@change="collapseChanged"
tabIndex="-1"
>
<label :for="`collapsible-${uniqueKey}`" class="lbl-toggle" tabindex="-1">
<h3>{{ title }}</h3>
</label>
<div class="collapsible-content">
<div class="content-inner">
<slot></slot>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CollapsableContainer',
props: {
uniqueKey: String,
title: String,
collapsed: Boolean,
cols: Number,
rows: Number,
color: String,
customStyles: String,
},
data() {
return {
isOpen: !this.collapsed,
};
},
methods: {
/* Check that row & column span is valid, and not over the max */
checkSpanNum(span, classPrefix) {
const maxSpan = 4;
let numSpan = /^\d*$/.test(span) ? parseInt(span, 10) : 1;
numSpan = (numSpan > maxSpan) ? maxSpan : numSpan;
return `${classPrefix}-${numSpan}`;
},
/* Removes all special characters, except those allowed in valid CSS */
sanitizeCustomStyles(userCss) {
return userCss ? userCss.replace(/[^a-zA-Z0-9- :;.]/g, '') : '';
},
/* If not already done, then add object structure to local storage */
initialiseStorage() {
const initStorage = () => localStorage.setItem('collapseState', JSON.stringify({}));
if (!localStorage.collapseState) initStorage(); // If not yet set, then init localstorage
try { // Check storage is valid JSON, and has not been corrupted
JSON.parse(localStorage.collapseState);
} catch {
initStorage();
}
return JSON.parse(localStorage.collapseState);
},
getCollapseState() {
const collapseStateObject = this.initialiseStorage();
let collapseState = !this.collapsed;
if (collapseStateObject[this.uniqueKey] !== undefined) {
collapseState = collapseStateObject[this.uniqueKey];
}
return collapseState;
},
setCollapseState(id, newState) {
// Get the current localstorage collapse state object
const collapseState = JSON.parse(localStorage.collapseState);
// Add the new state to it
collapseState[id] = newState;
// Stringify, and set the new object into local storage
localStorage.setItem('collapseState', JSON.stringify(collapseState));
},
collapseChanged(whatChanged) {
this.initialiseStorage();
this.setCollapseState(this.uniqueKey.toString(), whatChanged.srcElement.checked);
},
},
};
</script>
<style scoped lang="scss">
@import '@/styles/constants.scss';
@import '@/styles/media-queries.scss';
.collapsable {
padding: 5px;
margin: 10px;
border-radius: $curve-factor;
background: var(--primary);
// background: -webkit-linear-gradient(to left top, #9F86FF, #1CA8DD, #007AE1);
// background: linear-gradient(to left top, #9F86FF, #1CA8DD, #007AE1);
box-shadow: 1px 1px 2px #130f23;
height: fit-content;
width: 100%;
width: stretch;
grid-row-start: span 1;
&.row-2 { grid-row-start: span 2; }
&.row-3 { grid-row-start: span 3; }
&.row-4 { grid-row-start: span 4; }
grid-column-start: span 1;
@include tablet-up {
&.col-2 { grid-column-start: span 2; }
&.col-3 { grid-column-start: span 2; }
&.col-4 { grid-column-start: span 2; }
}
@include laptop-up {
&.col-2 { grid-column-start: span 2; }
&.col-3 { grid-column-start: span 3; }
&.col-4 { grid-column-start: span 3; }
}
@include monitor-up {
&.col-2 { grid-column-start: span 2; }
&.col-3 { grid-column-start: span 3; }
&.col-4 { grid-column-start: span 4; }
}
.wrap-collabsible {
margin-bottom: 1.2rem 0;
}
input[type='checkbox'] {
display: none;
}
label {
outline: none;
}
.lbl-toggle {
display: block;
padding: 0.25rem;
cursor: pointer;
border-radius: $curve-factor;
transition: all 0.25s ease-out;
text-align: left;
color: var(--background-transparent);
h3 {
margin: 0;
padding: 0;
display: inline;
}
}
.lbl-toggle:hover {
color: var(--background);
}
.lbl-toggle::before {
content: ' ';
display: inline-block;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 5px solid currentColor;
vertical-align: middle;
margin-right: .7rem;
transform: translateY(-2px);
transition: transform .2s ease-out;
}
.toggle:checked + .lbl-toggle::before {
transform: rotate(90deg) translateX(-3px);
}
.collapsible-content {
max-height: 0px;
overflow: hidden;
transition: max-height .25s ease-in-out;
background: var(--background-transparent);
border-radius: 0 0 $inner-radius $inner-radius;
}
.toggle:checked + .lbl-toggle + .collapsible-content {
max-height: 1000px;
}
.toggle:checked + .lbl-toggle {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.collapsible-content .content-inner {
padding: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<modal :name="name" :resizable="true" width="80%" height="80%" @closed="$emit('closed')">
<div slot="top-right" @click="hide()">Close</div>
<a @click="hide()" class="close-button" title="Close">x</a>
<iframe :src="url" @keydown.esc="close" class="frame"/>
</modal>
</template>
<script>
export default {
name: 'IframeModal',
props: {
name: String,
},
data: () => ({
url: '#',
}),
methods: {
show: function show(url) {
this.url = url;
this.$modal.show(this.name);
},
hide: function hide() {
this.$modal.hide(this.name);
},
},
};
</script>
<style lang="scss">
.frame {
width: 100%;
height: 100%;
border: none;
}
.close-button {
position: absolute;
right: 0;
padding: 0.5rem;
border: 0;
border-radius: 0 0 0 10px;
background: var(--primary);
color: var(--background);
border-left: 1px solid var(--primary);
border-bottom: 1px solid var(--primary);
cursor: pointer;
&:hover {
background: var(--background);
color: var(--primary);
}
}
</style>

View File

@@ -0,0 +1,246 @@
<template ref="container">
<a @click="itemOpened"
:href="target !== 'iframe' ? url : '#'"
:target="target === 'newtab' ? '_blank' : ''"
:class="`item ${!icon? 'short': ''}`"
:id="`link-${id}`"
v-tooltip="getTooltipOptions()"
rel="noopener noreferrer"
tabindex="0"
>
<!-- Item Text -->
<div class="tile-title" :id="`tile-${id}`">
<span class="text">{{ title }}</span>
<div class="overflow-dots">...</div>
</div>
<!-- Item Icon -->
<Icon :icon="icon" :url="url" />
<!-- Small icon, showing opening method on hover -->
<ItemOpenMethodIcon class="opening-method-icon" :openingMethod="target" :isSmall="!icon" />
</a>
</template>
<script>
import Icon from '@/components/LinkItems/ItemIcon.vue';
import ItemOpenMethodIcon from '@/components/LinkItems/ItemOpenMethodIcon';
export default {
name: 'Item',
props: {
id: String, // The unique ID of a tile (e.g. 001)
title: String, // The main text of tile, required
subtitle: String, // Optional sub-text
description: String, // Optional tooltip hover text
icon: String, // Optional path to icon, within public/img/tile-icons
svg: String, // Optional vector graphic, that is then dynamically filled
color: String, // Optional background color, specified in hex code
url: String, // URL to the resource, optional but recommended
target: { // Where resource will open, either 'newtab', 'sametab' or 'iframe'
type: String,
default: 'newtab',
validator: (value) => ['newtab', 'sametab', 'iframe'].indexOf(value) !== -1,
},
},
data() {
return {
getId: this.id,
};
},
components: {
Icon,
ItemOpenMethodIcon,
},
methods: {
/* Called when an item is clicked, manages the opening of iframe & resets the search field */
itemOpened(e) {
if (e.altKey || this.target === 'iframe') {
e.preventDefault();
this.$emit('triggerModal', this.url);
} else {
this.$emit('itemClicked');
}
},
/**
* Detects overflowing text, shows ellipse, and allows is to marguee on hover
* The below code is horifically bad, it is embarassing that I wrote it...
*/
manageTitleEllipse() {
const tileElem = document.getElementById(`tile-${this.getId}`);
if (tileElem) {
const isOverflowing = tileElem.scrollHeight > tileElem.clientHeight
|| tileElem.scrollWidth > tileElem.clientWidth;
if (isOverflowing) tileElem.className += ' is-overflowing';
} // Note from present me to past me: WTF?!
},
/* Returns configuration object for the tooltip */
getTooltipOptions() {
return {
disabled: !this.description,
content: this.description,
trigger: 'hover focus',
hideOnTargetClick: true,
html: false,
delay: { show: 350, hide: 200 },
};
},
},
mounted() {
this.manageTitleEllipse();
},
};
</script>
<style scoped lang="scss">
@import '../../../src/styles/constants.scss';
/* Item wrapper */
.item-wrapper {
}
.item {
flex-grow: 1;
height: 100px;
position: relative;
color: var(--primary);
vertical-align: middle;
margin: 0.5rem;
background: #607d8b33;
text-align: center;
padding: 2px;
border: 2px solid transparent;
border-radius: $curve-factor;
box-shadow: 1px 1px 2px #373737;
cursor: pointer;
&:hover {
box-shadow: 1px 2px 4px #373737;
background: #607d8b4d;
}
&:focus {
border: 2px solid var(--primary);
outline: none;
}
&.short {
height: 18px;
}
.item {
color: var(--primary);
}
}
/* Text in tile */
.tile-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 120px;
height: 30px;
overflow: hidden;
position: relative;
padding: 0;
span.text {
position: absolute;
white-space: nowrap;
transition: 1s;
float: left;
left: 0;
}
&:not(.is-overflowing) span.text{
width: 100%;
}
.overflow-dots {
opacity: 0;
}
&.is-overflowing {
span.text {
overflow: hidden;
}
.overflow-dots {
display: block;
opacity: 1;
// background: var(--background-transparent);
background: #283e51;
position: absolute;
z-index: 5;
right: 0;
transition: opacity 0.1s ease-in;
}
}
}
.opening-method-icon {
display: none; // Hidden by default, visible on hover
}
/* Manage hover and focus actions */
.item:hover, .item:focus {
/* Show opening-method icon */
.opening-method-icon {
display: block;
}
/* Trigger text-marquee for text that doesn't fit */
.tile-title.is-overflowing{
.overflow-dots {
opacity: 0;
}
span.text {
transform: translateX(calc(120px - 100%));
}
}
/* Colourize icons on hover */
.tile-svg {
filter: drop-shadow(4px 8px 3px var(--transparent-50));
}
.tile-icon {
filter:
drop-shadow(4px 8px 3px var(--transparent-50))
saturate(2);
}
}
.tile-icon {
width: 60px;
filter: drop-shadow(2px 4px 6px var(--transparent-50)) saturate(0.65);
}
.tile-svg {
width: 56px;
}
</style>
<!-- An un-scoped style tag, since tooltip is outside this DOM tree -->
<style lang="scss">
.tooltip {
padding: 0.2rem 0.5rem;
background: #0b1021cc;
border: 1px solid #0b1021;
border-radius: 3px;
color: #fff;
max-width: 250px;
}
.tooltip-arrow {
border-width: 5px 5px 0 5px;
border-left-color: transparent!important;
border-right-color: transparent!important;
border-bottom-color: transparent!important;
bottom: -11px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
width: 0;
height: 0;
border-style: solid;
position: absolute;
margin: 5px;
border-color: #0b1021cc;
z-index: 3;
}
.disabled-link {
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<Collapsable
:title="title"
:uniqueKey="groupId"
:collapsed="displayData.collapsed"
:cols="displayData.cols"
:rows="displayData.rows"
:color="displayData.color"
:customStyles="displayData.customStyles"
>
<div v-if="!items || items.length < 1" class="no-items">
No Items to Show Yet
</div>
<div v-else class="there-are-items">
<Item
v-for="(item, index) in items"
:id="`${index}_${makeId(item.title)}`"
:key="`${index}_${makeId(item.title)}`"
:url="item.url"
:title="item.title"
:description="item.description"
:icon="item.icon"
:target="item.target"
:svg="item.svg"
@itemClicked="$emit('itemClicked')"
@triggerModal="triggerModal"
/>
<div ref="modalContainer"></div>
</div>
<IframeModal
:ref="`iframeModal-${groupId}`"
:name="`iframeModal-${groupId}`"
@closed="$emit('itemClicked')"
/>
</Collapsable>
</template>
<script>
import Item from '@/components/LinkItems/Item.vue';
import Collapsable from '@/components/LinkItems/Collapsable.vue';
import IframeModal from '@/components/LinkItems/IframeModal.vue';
export default {
name: 'ItemGroup',
props: {
groupId: String,
title: String,
displayData: Object,
items: Array,
},
components: {
Collapsable,
Item,
IframeModal,
},
methods: {
/* Returns a unique lowercase string, based on name, for section ID */
makeId(str) {
return str.replace(/\s+/g, '-').replace(/[^a-zA-Z ]/g, '').toLowerCase();
},
/* Opens the iframe modal */
triggerModal(url) {
this.$refs[`iframeModal-${this.groupId}`].show(url);
},
},
};
</script>
<style scoped lang="scss">
@import '../../../src/styles/constants.scss';
.no-items {
width: 100px;
margin: 0 auto;
padding: 0.8rem;
text-align: center;
cursor: default;
border-radius: $curve-factor;
background: #607d8b33;
color: var(--primary);
box-shadow: 1px 1px 2px #373737;
}
.there-are-items {
height: 100%;
display: flex;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<img
v-if="icon"
:src="getAppropriateImgPath(icon, url)"
class="tile-icon"
/>
</template>
<script>
export default {
name: 'Item',
props: {
icon: String,
url: String,
},
methods: {
/* Check if a string is in a URL format. Used to identify tile icon source */
isUrl(str) {
const pattern = new RegExp(/(http|https):\/\/(\w+:{0,1}\w*)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%!\-/]))?/);
return pattern.test(str);
},
/* Returns true if the input is a path to an image file */
isImage(img) {
const fileExtRegex = /(?:\.([^.]+))?$/;
const validImgExtensions = ['png', 'jpg'];
const splitPath = fileExtRegex.exec(img);
if (splitPath.length >= 1) return validImgExtensions.includes(splitPath[1]);
return false;
},
/* Get favicon URL, for items which use the favicon as their icon */
getFavicon(fullUrl) {
const isLocalIP = /(127\.)|(192\.168\.)|(10\.)|(172\.1[6-9]\.)|(172\.2[0-9]\.)|(172\.3[0-1]\.)|(::1$)|([fF][cCdD])|(localhost)/;
if (isLocalIP.test(fullUrl)) { // Check if using a local IP format or localhost
const urlParts = fullUrl.split('/');
// For locally running services, use the default path for favicon
if (urlParts.length >= 2) return `${urlParts[0]}/${urlParts[1]}/${urlParts[2]}/favicon.ico`;
} else if (fullUrl.includes('http')) {
// For publicly accessible sites, a more reliable method is using Google's API
return `https://s2.googleusercontent.com/s2/favicons?domain=${fullUrl}`;
}
return '';
},
/* Checks if the icon is from a local image, remote URL, SVG or font-awesome */
getAppropriateImgPath(img, url) {
const imageType = this.determineImageType(img);
switch (imageType) {
case 'url':
return img;
case 'img':
return `/img/item-icons/tile-icons/${img}`;
case 'favicon':
return this.getFavicon(url);
case 'svg':
return img;
case 'fas':
return img;
default:
return '';
}
},
/* Checks if the icon is from a local image, remote URL, SVG or font-awesome */
determineImageType(img) {
let imgType = '';
if (this.isUrl(img)) {
imgType = 'url';
} else if (this.isImage(img)) {
imgType = 'img';
// } else if (fileExtRegex.exec(img)[1] === 'svg') {
// imgType = 'svg';
} else if (img.includes('fas')) {
imgType = 'fas';
} else if (img === 'favicon') {
imgType = 'favicon';
} else {
imgType = 'none';
}
return imgType;
},
},
};
</script>
<style scoped lang="scss">
.tile-icon {
width: 60px;
filter: drop-shadow(2px 4px 6px var(--transparent-50)) saturate(0.65);
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div :class="`opening-method-icon ${isSmall? 'short': ''}`">
<NewTabOpenIcon v-if="openingMethod === 'newtab'" />
<SameTabOpenIcon v-else-if="openingMethod === 'sametab'" />
<IframeOpenIcon v-else-if="openingMethod === 'iframe'" />
</div>
</template>
<script>
import NewTabOpenIcon from '@/assets/icons/open-new-tab.svg';
import SameTabOpenIcon from '@/assets/icons/open-current-tab.svg';
import IframeOpenIcon from '@/assets/icons/open-iframe.svg';
export default {
name: 'ItemOpenMethodIcon',
props: {
openingMethod: String,
isSmall: Boolean,
},
components: {
NewTabOpenIcon,
SameTabOpenIcon,
IframeOpenIcon,
},
};
</script>
<style scoped lang="scss">
.opening-method-icon {
svg {
position: absolute;
width: 1rem;
margin: 2px;
right: 0;
top: 0;
path {
fill: var(--primary-transparent);
}
}
&.short svg {
width: 0.5rem;
margin: 0;
}
}
</style>