Merge branch 'master' into prod

This commit is contained in:
axolotle
2021-04-08 16:20:51 +02:00
57 changed files with 3325 additions and 3644 deletions
+1716 -1871
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -12,7 +12,9 @@
},
"dependencies": {
"axios": "^0.21.1",
"bootstrap-vue": "^2.21.2",
"core-js": "^3.6.5",
"d3": "^6.5.0",
"vue": "^2.6.11",
"vue-meta": "^2.4.0",
"vue-router": "^3.2.0",
+1 -1
View File
@@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>En Françai(s)</title>
<title>En françaiS au pluriel</title>
</head>
<body>
<noscript>
+43 -17
View File
@@ -1,18 +1,38 @@
<template>
<div id="app">
<header>
<h1>
<router-link :to="{ name: 'home' }">En Français</router-link>
</h1>
<b-navbar>
<b-dropdown variant="link" no-caret>
<template #button-content>
<span class="navbar-toggler-icon" />
<span class="sr-only">Menu</span>
</template>
<b-dropdown-item v-for="name in subRoutes" :key="name" :to="{ name }">
{{ $t('sections.' + name) }}
</b-dropdown-item>
</b-dropdown>
<b-navbar-brand :to="{ name: 'home' }">
{{ $t('title') }}
</b-navbar-brand>
<b-nav class="ml-auto" pills>
<b-nav-item
v-for="name in mainRoutes" :key="name"
:to="{ name }" :active="$route.name === name"
>
{{ $t('sections.' + name) }}
</b-nav-item>
</b-nav>
</b-navbar>
<router-view name="options" />
</header>
<main id="main">
<router-view />
</main>
<footer>
<p>footer</p>
</footer>
</div>
</template>
@@ -20,18 +40,24 @@
export default {
name: 'App',
metaInfo: {
// if no subcomponents specify a metaInfo.title, this title will be used
title: 'Home',
// all titles will be injected into this template
titleTemplate: '%s | En Français',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
]
metaInfo () {
return {
// if no subcomponents specify a metaInfo.title, try to get one from the route name.
title: this.$t('sections.' + this.$route.name, ''),
// all titles will be injected into this template
titleTemplate: '%s | ' + this.$t('title')
}
},
data () {
return {
mainRoutes: ['library', 'kit', 'gallery'],
subRoutes: ['home', 'introduction', 'blog', 'contact']
}
}
}
</script>
<style lang="scss" scoped>
<style lang="scss">
@import '@/assets/scss/main.scss';
</style>
+37
View File
@@ -0,0 +1,37 @@
fragment TextCardFields on TextInterface {
id
bundle
title
content: texte
images {
id
url
alt
}
videos {
url
}
files: fichiers {
fid
filename
filemime
url
}
families: familles {
id
uuid
name
}
# field_titre
authors: auteurs {
id
uuid
name
}
edition {
id
uuid
name
}
type: __typename
}
+9
View File
@@ -0,0 +1,9 @@
fragment TextTreeFields on TextInterface {
id
title
families: familles {
id
name
}
type: __typename
}
+11
View File
@@ -0,0 +1,11 @@
#import "../fragments/TextTreeFields.gql"
fragment TextrefTreeFields on Textref {
...TextTreeFields
children: text_produits {
...TextTreeFields
parents: text_de_depart {
...TextTreeFields
}
}
}
+46
View File
@@ -0,0 +1,46 @@
#import "../fragments/TextCardFields.gql"
query TextRef ($id: Int!) {
text: texte (id: $id) {
...TextCardFields
... on Textref {
children: text_produits {
id
}
siblings: text_en_rebond {
id
title
authors: auteurs {
name
}
edition {
name
}
}
tags {
id
name
}
# degres_detrangement: Int
notes {
note
number: numero
}
}
... on Textprod {
tags: tagsprod {
id
name
}
notes {
note
number: numero
links: liens {
id
}
}
}
}
}
-5
View File
@@ -1,5 +0,0 @@
query TextRef ($id: Int!) {
textref (id: $id) {
title
}
}
+16
View File
@@ -0,0 +1,16 @@
#import "../fragments/TextrefTreeFields.gql"
query TextdepartRecursive($id: Int!) {
textref(id: $id) {
...TextrefTreeFields
siblings: text_en_rebond {
...TextrefTreeFields
siblings: text_en_rebond {
...TextrefTreeFields
siblings: text_en_rebond {
...TextrefTreeFields
}
}
}
}
}
@@ -0,0 +1,7 @@
#import "../fragments/TextrefTreeFields.gql"
query TextdepartRecursive($id: Int!) {
textref(id: $id) {
INPUT
}
}
+6
View File
@@ -0,0 +1,6 @@
query TextsDepart {
textsdepart {
id,
title
}
}
+3
View File
@@ -0,0 +1,3 @@
export { default as TextsDepart } from './TextsDepart.gql'
export { default as TextCard } from './TextCard.gql'
export { default as TextdepartRecursive } from './TextdepartRecursive.gql'
+60
View File
@@ -0,0 +1,60 @@
/*!
* Import of all needed bootstrap files except variables, functions and mixins
*/
// ┌─╮╭─╮╭─╮╶┬╴╭─╴╶┬╴┌─╮╭─┐┌─╮
// │╶┤│ ││ │ │ ╰─╮ │ ├┬╯├─┤├─╯
// └─╯╰─╯╰─╯ ╵ ╶─╯ ╵ ╵ ╰╵ ╵╵
// TODO OPTI: import only used
@import "~bootstrap/scss/root";
@import "~bootstrap/scss/reboot";
@import "~bootstrap/scss/type";
@import "~bootstrap/scss/images";
@import "~bootstrap/scss/code";
@import "~bootstrap/scss/grid";
@import "~bootstrap/scss/tables";
@import "~bootstrap/scss/forms";
@import "~bootstrap/scss/buttons";
@import "~bootstrap/scss/transitions";
@import "~bootstrap/scss/dropdown";
@import "~bootstrap/scss/button-group";
@import "~bootstrap/scss/input-group";
@import "~bootstrap/scss/custom-forms";
@import "~bootstrap/scss/nav";
@import "~bootstrap/scss/navbar";
@import "~bootstrap/scss/card";
@import "~bootstrap/scss/breadcrumb";
@import "~bootstrap/scss/pagination";
@import "~bootstrap/scss/badge";
@import "~bootstrap/scss/jumbotron";
@import "~bootstrap/scss/alert";
@import "~bootstrap/scss/progress";
@import "~bootstrap/scss/media";
@import "~bootstrap/scss/list-group";
@import "~bootstrap/scss/close";
@import "~bootstrap/scss/toasts";
@import "~bootstrap/scss/modal";
@import "~bootstrap/scss/tooltip";
@import "~bootstrap/scss/popover";
@import "~bootstrap/scss/carousel";
@import "~bootstrap/scss/spinners";
@import "~bootstrap/scss/utilities";
@import "~bootstrap/scss/print";
// ┌─╮╭─╮╭─╮╶┬╴╭─╴╶┬╴┌─╮╭─┐┌─╮ ╷ ╷╷ ╷┌─╴
// │╶┤│ ││ │ │ ╰─╮ │ ├┬╯├─┤├─╯╶─╴│╭╯│ │├─╴
// └─╯╰─╯╰─╯ ╵ ╶─╯ ╵ ╵ ╰╵ ╵╵ ╰╯ ╰─╯╰─╴
// TODO OPTI: import only used
@import "~bootstrap-vue/src/utilities";
// General styling needed for special form controls
@import "~bootstrap-vue/src/custom-controls";
// Include custom SCSS for components
@import "~bootstrap-vue/src/components/index";
+28
View File
@@ -0,0 +1,28 @@
// Texte de départ (id: 9)
$color-starting: white;
// Texte critique (id: 22)
$color-critical: #ffba7d;
// Constellation en écho (id: 63)
$color-echo: #e191ff;
// Réflexion théorique (id: 6)
$color-thinking: #ffeb91;
// Lecture rapprochée (id: 7)
$color-reading: #a5a5ff;
// Expérience sensible (id: 8)
$color-experience: #cdae87;
// Kit de désapprentissage (id: 23)
$color-kit: #ff8873;
$families: (
9: $color-starting,
22: $color-critical,
63: $color-echo,
6: $color-thinking,
7: $color-reading,
8: $color-experience,
23: $color-kit
);
@function setColorFromId($id) {
@return map-get($families, $id);
}
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
$bleuroi: rgb(61,82,102);
$bleuroilight: lighten(rgb(61,82,102), 30%);
$gris: rgb(200,204,204);
$grisclair: rgb(230,234,234);
$grisfonce: rgb(57,57,64);
$or: rgb(179,161,125);
$rouge: rgb(204,61,82);
-93
View File
@@ -1,93 +0,0 @@
@import '../fonts/libertinus/libertinus.css';
@import '../fonts/notosans/notosans.css';
// @import '../fonts/notosans/notosans-semicondensed.css';
// @import '../fonts/notosans/notosans-condensed.css';
// @import '../fonts/notosans/notosans-extracondensed.css';
@mixin fontsans {
font-family: "noto_sans";
}
@mixin fontserif {
font-family: 'libertinus_serif';
}
body{
@include fontserif;
font-weight: 400;
// font-family: 'noto_sans';
// font-weight: 900;
// font-style: italic;
}
#header-menu,
#footer-tabs,
#search,
footer[role="tools"] .row>header,
footer[role="tools"] .row>nav{
@include fontsans;
}
@mixin title1blue {
@include fontserif;
font-size: 2.268em;
color: $bleuroi;
font-weight: 400;
margin:0;
}
@mixin title1black {
@include fontserif;
font-size: 1.512em;
color: $grisfonce;
font-weight: 400;
margin:0;
}
@mixin title2black {
@include fontserif;
font-size: 1.134em;
color: $grisfonce;
font-weight: 400;
margin:0;
}
@mixin title3black {
@include fontserif;
font-size: 1em;
color: $grisfonce;
font-weight: 400;
margin:0;
}
@mixin title4black {
@include fontserif;
font-size: 0.882em;;
color: $gris;
font-weight: 400;
margin:0;
}
// TEI
@mixin teititlefrontblue {
@include fontserif;
font-size: 2.6em;
color: $bleuroi;
font-weight: 400;
margin:0;
text-align: center;
}
@mixin teititle1blue {
@include fontserif;
font-size: 1.8em;
color: $bleuroi;
font-weight: 400;
margin:0;
}
@mixin fontcaption {
@include fontsans;
font-size: 0.643em;
line-height: 1.1em;
}
-105
View File
@@ -1,105 +0,0 @@
// http://www.thesassway.com/intermediate/simple-grid-mixins
@mixin row() {
display:flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: stretch;
@media only screen and (max-width: $small-bp) {
flex-wrap:wrap;
}
}
.row{
@include row;
}
.row-rl{
@include row;
}
// small
.small-row {
@media only screen and (max-width: $small-bp) {
@include row;
}
}
// medium
.med-row {
@media only screen and (min-width: $small-bp+1) and (max-width: $med-bp) {
@include row;
}
}
// large
.large-row {
@media only screen and (min-width: $med-bp+1) {
@include row;
}
}
%col-reset {
box-sizing: border-box;
}
@mixin col($col, $offset: 0, $sum: $default_sum, $gap: $default_gap, $align: top) {
@extend %col-reset;
padding-left: $gap*$offset;
@if $col == $default_sum {
// if last col, then no gap
padding-right: 0;
}@else{
padding-right: $gap;
}
&:last-child{padding-right: 0;}
// no offset with flex ??
// margin-left: percentage(($col/$sum)*$offset);
// col width
flex-basis: percentage($col/$sum);
}
@for $c from 1 through $default_sum {
.col-#{$c} {
@include col($c);
}
// small
.small-col-#{$c} {
@media only screen and (max-width: $small-bp) {
@include col($c);
}
}
// medium
.med-col-#{$c} {
@media only screen and (min-width: $small-bp+1) and (max-width: $med-bp) {
@include col($c);
}
}
// large
.large-col-#{$c} {
@media only screen and (min-width: $med-bp+1) {
@include col($c);
}
}
}
@for $c from 1 through $default_sum - 1 {
@for $o from 1 through $default_sum - $c {
.col-#{$c}-offset-#{$o} {
@include col($c, $o);
}
}
}
// TODO: replace with align-self:flex-start or flex-end
// .col.float-right{
// float: right;
// padding-right: 0;
// padding-left: $default_gap;
// }
-92
View File
@@ -1,92 +0,0 @@
// http://www.thesassway.com/intermediate/simple-grid-mixins
@mixin row() {
// font-size: 0;
// white-space: nowrap;
position: relative;
// >*{
// font-size: 16px;
// }
&:after{
content:"";
clear:both;
display: block;
}
}
%col-reset {
width: 100%;
// display: inline-block;
// white-space:normal;
// font-size: 16px;
float:left;
box-sizing: border-box;
}
@mixin col($col, $offset: 0, $sum: $default_sum, $gap: $default_gap, $align: top) {
@extend %col-reset;
padding-left: $gap*$offset;
@if $col == $default_sum {
padding-right: 0;
}@else{
padding-right: $gap;
}
&:last-child{padding-right: 0;}
margin-left: percentage(($col/$sum)*$offset);
// @media only screen and (min-width: 768px) {
width: percentage($col/$sum);
// vertical-align: $align;
// }
}
.row{
@include row;
// html:not(.js) &{
// overflow-y: auto;
// }
}
@for $c from 1 through $default_sum {
.col-#{$c} {
@include col($c);
}
// small
.small-col-#{$c} {
@media only screen and (max-width: $small-bp) {
@include col($c);
}
}
// medium
.med-col-#{$c} {
@media only screen and (min-width: $small-bp+1) and (max-width: $med-bp) {
@include col($c);
}
}
// large
.large-col-#{$c} {
@media only screen and (min-width: $med-bp+1) {
@include col($c);
}
}
}
@for $c from 1 through $default_sum - 1 {
@for $o from 1 through $default_sum - $c {
.col-#{$c}-offset-#{$o} {
@include col($c, $o);
}
}
}
.col.float-right{
float: right;
padding-right: 0;
padding-left: $default_gap;
}
-94
View File
@@ -1,94 +0,0 @@
$side-padding:3em;
body, html{
position: relative;
width: 100%;
height:100%;
font-family: sans-serif;
font-style: normal;
margin:0;
padding:0;
}
body{
overflow:hidden;
}
#root{
display: flex;
flex-direction: column;
width: 100vw;
height:100vh;
%layout-element{
width:100vw;
box-sizing:border-box;
}
header[role="banner"]{
z-index:10;
flex: 0 0 auto;
@extend %layout-element;
padding:1em $side-padding 1em $side-padding;
@media only screen and (max-width: $small-bp) {
padding:1em $side-padding/2 0 $side-padding/2;
}
}
section[role="main-content"]{
display: flex;
flex:1 1 auto;
@extend %layout-element;
overflow: hidden;
position: relative;
>.wrapper{
position: relative;
padding:0 $side-padding 0 $side-padding;
// height:100%; max-height:100%;
display: flex;
flex: 1;
overflow-y: hidden;
overflow-x: hidden;
>*{
@include fade-transition;
}
@media only screen and (max-width: $small-bp) {
overflow-y: auto;
padding:0 $side-padding/2 0 $side-padding/2;
}
}
.main-content-layout{
position: relative;
// https://stackoverflow.com/a/33644245
display: flex;
flex: 1;
>section{
max-height: 100%;
}
>header,
>section>.wrapper,
>nav{
box-sizing: border-box;
max-height: 100%;
padding-top:1em;
}
>section>.wrapper,
>nav{
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
@media only screen and (max-width: $small-bp) {
.main-content-layout{
display: flex;
flex-direction: column;
}
}
}
footer[role="tools"]{
flex:0 0 auto;
@extend %layout-element;
// padding-bottom: 1em;
// >*{
// padding:0.5em 1em;
// }
}
}
-40
View File
@@ -1,40 +0,0 @@
html, body{
font-size:16px;
line-height: 0.9;
margin:0; padding:0;
background: white;
}
a{
color: inherit;
text-decoration: none;
}
a, a:focus, a:active { outline: none; }
a:focus{ -moz-outline-style: none; }
ul {
padding:0;
margin:0;
}
li{
padding:0;
margin:0;
list-style: none;
}
.visualy-hidden {
position: absolute !important;
clip: rect(1px, 1px, 1px, 1px);
overflow: hidden;
height: 1px;
width: 1px;
word-wrap: normal;
}
.visualy-hidden:active,
.visualy-hidden:focus {
position: static !important;
clip: auto;
overflow: visible;
height: auto;
width: auto;
}
-67
View File
@@ -1,67 +0,0 @@
// _ _ _ _
// | |_ _ _ __ _ _ _ __(_) |_(_)___ _ _ ___
// | _| '_/ _` | ' \(_-< | _| / _ \ ' \(_-<
// \__|_| \__,_|_||_/__/_|\__|_\___/_||_/__/
@mixin accordeon-transition($h){
&.accordeon-enter,
&.accordeon-leave-to{
// opacity: 0;
height:0.01vh;
padding-top: 0;
padding-bottom: 0;
// transform: translateY(100%);
// transform: translate3d(0, 100%, 0);
}
&.accordeon-enter-to,
&.accordeon-leave{
// opacity:1;
height:$h;
// transform: translateY(0%);
// transform: translate3d(0, 0, 0);
}
&.accordeon-enter-active{
transition: all 300ms ease-in-out;
}
&.accordeon-leave-active{
transition: all 200ms ease-in-out;
}
}
@mixin fade-transition() {
&.fade-enter,
&.fade-leave-to{
opacity: 0;
}
&.fade-enter-to,
&.fade-leave{
opacity:1;
}
&.fade-enter-active{
transition: all 300ms ease-in-out;
}
&.fade-leave-active{
transition: all 200ms ease-in-out;
}
}
.fade-enter-active{
transition: all 300ms ease-in-out;
}
.fade-leave-active{
transition: all 200ms ease-in-out;
}
.fade-enter,
.fade-leave-active {
opacity: 0
}
// .edition-texts-enter-active, .edition-texts-leave-active {
// transition: all 1s;
// }
// .edition-texts-enter, .edition-texts-leave-to /* .list-leave-active below version 2.1.8 */ {
// max-height: 0;
// // transform: translateY(30px);
// }
-10
View File
@@ -1,10 +0,0 @@
$base_font_size:16px;
// grid
$default_gap: 1em;
$default_sum: 12; // total number of columns
$small-bp:768px;
$med-bp:1080px;
$ipad-bp: 1536px;
$large-bp:1900px;
+40
View File
@@ -0,0 +1,40 @@
/*
╭─────────────────────────────────────────────────────────────────╮
│ │
│ /!\ DO NOT IMPORT OR DEFINE ACTUAL RULES INTO THIS FILE /!\ │
│ │
│ Only things that disappear after scss compilation is allowed. │
│ │
╰─────────────────────────────────────────────────────────────────╯
This file is magically imported into every components so that scss variables and
mixins can be accessed.
But if some rules are defined here, they will be copied into the final build as many
times as there are components…
*/
// Custom variables must be imported first.
@import 'bootstrap-overrides/variables';
@import 'variables';
// ┌─╮╭─╮╭─╮╶┬╴╭─╴╶┬╴┌─╮╭─┐┌─╮
// │╶┤│ ││ │ │ ╰─╮ │ ├┬╯├─┤├─╯
// └─╯╰─╯╰─╯ ╵ ╶─╯ ╵ ╵ ╰╵ ╵╵
@import '~bootstrap/scss/functions';
@import '~bootstrap/scss/variables';
@import '~bootstrap/scss/mixins';
// ┌─╮╭─╮╭─╮╶┬╴╭─╴╶┬╴┌─╮╭─┐┌─╮ ╷ ╷╷ ╷┌─╴
// │╶┤│ ││ │ │ ╰─╮ │ ├┬╯├─┤├─╯╶─╴│╭╯│ │├─╴
// └─╯╰─╯╰─╯ ╵ ╶─╯ ╵ ╵ ╰╵ ╵╵ ╰╯ ╰─╯╰─╴
@import "~bootstrap-vue/src/variables";
// mixins and functions overides must be imported after bootstrap's definitions.
@import 'bootstrap-overrides/mixins';
+41
View File
@@ -0,0 +1,41 @@
@import 'globals';
@import 'bootstrap';
html, body, #app {
height: 100%;
}
#app {
display: flex;
flex-direction: column;
}
main {
height: 100%;
}
// Layouts
.view {
display: flex;
flex-direction: column;
height: 100%;
}
.split-screen {
display: flex;
height: 100%;
& > * {
flex-basis: 50%;
}
}
.container-fill {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
+176
View File
@@ -0,0 +1,176 @@
<template>
<svg
width="100%" height="100%"
ref="svg" :id="id"
>
<template v-if="ready">
<path
v-for="(item, index) in links"
:key="`link_${index}`"
:d="lineGenerator([item.source, item.target])"
stroke="black"
/>
<template v-for="(node, index) in nodes">
<circle
:key="'circle' + index"
:cx="node.x"
:cy="node.y"
:class="node.data.class + (node.data.first ? ' first' : '')"
:r="node.children ? 7 : 5"
tabindex="0"
@click="onNodeClick(node.data)"
@keyup.enter="onNodeClick(node.data)"
>
<title>{{ node.data.title }}</title>
</circle>
<text
v-if="showId"
:key="'text' + index" :x="node.x + 7" :y="node.y-7"
>{{ node.data.id }}</text>
</template>
</template>
</svg>
</template>
<script>
import { line } from 'd3-shape'
import { selectAll } from 'd3-selection'
import { drag } from 'd3-drag'
import {
forceSimulation, forceLink, forceManyBody,
forceX, forceY, forceCenter
} from 'd3-force'
export default {
/*
This component uses vue as the svg renderer and the rest is handle with d3 functions
(events, data computing, etc.)
*/
name: 'NodeMap',
props: {
nodes: { type: Array, required: true },
links: { type: Array, required: true },
id: { type: String, default: 'node-map' },
showId: { type: Boolean, default: true }
},
data () {
return {
ready: false,
width: 100,
height: 100,
simulation: forceSimulation(),
lineGenerator: line().x(node => node.x).y(node => node.y)
}
},
methods: {
updateSize () {
const { width, height } = this.$el.getBoundingClientRect()
Object.assign(this.$data, { width, height })
},
init () {
this.updateSize()
this.nodes.forEach(node => {
this.$set(node, 'x', this.width * 0.5)
this.$set(node, 'y', this.height * 0.5)
})
this.setupForces()
// Wait for DOM update so d3 can select
this.$nextTick(this.initListeners)
this.ready = true
},
initListeners () {
// TODO replace with vue events ?
selectAll(`#${this.id} circle`)
.data(this.nodes)
.call(drag()
.on('start', this.onNodeDragStart)
.on('drag', this.onNodeDrag)
.on('end', this.onNodeDragEnd)
)
},
setupForces () {
this.simulation
.nodes(this.nodes)
.force('link', forceLink(this.links).id(d => d.id).distance(50))
.force('charge', forceManyBody().strength(-150))
.force('x', forceX())
.force('y', forceY())
.force('center', forceCenter(this.width * 0.5, this.height * 0.5))
},
// D3 DRAG EVENT HANDLERS
onNodeDragStart (e, node) {
if (!e.active) {
this.simulation.alphaTarget(0.8).restart()
}
node.fx = node.x
node.fy = node.y
},
onNodeDrag (e, node) {
node.fx = e.x
node.fy = e.y
},
onNodeDragEnd (e, node) {
if (!e.active) {
this.simulation.alphaTarget(0)
}
node.fx = null
node.fy = null
},
// BASIC HANDLERS
onNodeClick (nodeData) {
const { id, parents } = nodeData
if (parents) {
this.$emit('open', parents[0].id, id)
} else {
this.$emit('open', id)
}
}
},
async mounted () {
this.init()
}
}
</script>
<style lang="scss" scoped>
path {
stroke: grey;
}
text {
font-size: 0.7rem;
user-select: none;
}
@each $id in map-keys($families){
.family-#{$id} {
fill: setColorFromId($id);
@if $id == 9 {
stroke: black;
}
}
}
.first {
stroke-width: 2px;
}
</style>
@@ -0,0 +1,53 @@
<template>
<b-form-tags
v-bind="$attrs" v-on="$listeners"
:value="value"
no-outer-focus
>
<template v-slot="{ tags, disabled, addTag, removeTag }">
<b-dropdown
:text="buttonText" size="sm"
variant="outline-secondary" class="d-block rounded-0"
>
<b-dropdown-item-button
v-for="option in availableOptions" :key="option"
@click="addTag(option)"
>
{{ option }}
</b-dropdown-item-button>
</b-dropdown>
<ul v-if="tags.length > 0" class="list-inline d-inline-block">
<li v-for="tag in tags" :key="tag" class="list-inline-item">
<b-form-tag
:title="tag" :disabled="disabled" pill
@remove="removeTag(tag)"
>
{{ tag }}
</b-form-tag>
</li>
</ul>
</template>
</b-form-tags>
</template>
<script>
export default {
name: 'MultipleTagsSelect',
props: {
value: { type: Array, required: true },
options: { type: Array, required: true },
buttonText: { type: String, required: true }
},
computed: {
availableOptions () {
return this.options.filter(opt => this.value.indexOf(opt) === -1)
}
}
}
</script>
<style lang="scss" scoped>
</style>
+22
View File
@@ -0,0 +1,22 @@
<template>
<div class="border p-3">
<div>
<span class="font-weight-bold">{{ component.$options.name }}</span>
PROPS: {{ component.$props }}
</div>
<slot name="default" />
</div>
</template>
<script>
export default {
name: 'ComponentDebug',
props: {
component: { type: Object, required: true }
}
}
</script>
<style lang="scss" scoped>
</style>
+62
View File
@@ -0,0 +1,62 @@
<template>
<text-card-base v-bind="$attrs">
<template v-slot:header-extra="{ text }">
<b-nav class="ml-auto flex-nowrap" pills>
<template v-if="children">
<b-nav-item
v-for="child in text.children" :key="child.id"
@click="$emit('open', text.id, child.id)"
:active="children.includes(child.id)"
>
{{ child.id }}
</b-nav-item>
</template>
<b-nav-item>
<b-button-close @click="$emit('close', text.id)" />
</b-nav-item>
</b-nav>
</template>
<template v-slot:footer-extra="{ text, toCommaList }">
<b-button
v-if="text.siblings"
:id="'siblings-' + text.id"
>
{{ $t('siblings') }}
</b-button>
<b-popover
v-if="text.siblings"
:target="'siblings-' + text.id" triggers="click" placement="top"
>
<div v-for="sibling in text.siblings" :key="sibling.id">
<h6>
<span>{{ toCommaList(sibling.authors) }},</span>
<span>{{ sibling.title }},</span>
<span>{{ sibling.edition }}</span>
</h6>
</div>
</b-popover>
</template>
</text-card-base>
</template>
<script>
import TextCardBase from './TextCardBase'
export default {
name: 'TextCard',
components: {
TextCardBase
},
props: {
children: { type: Array, default: null }
}
}
</script>
<style lang="scss" scoped>
</style>
+93
View File
@@ -0,0 +1,93 @@
<template>
<b-card no-body tag="article" class="text-card">
<b-overlay class="h-100" :show="loading">
<div v-if="text" class="container-fill">
<b-card-header header-tag="header">
<h4>
<template v-if="text.type === 'Textref'">
<span>{{ toCommaList(text.authors) }},</span>
<span>{{ text.title }},</span>
<span>{{ text.edition }}</span>
</template>
<template v-else>
{{ text.title }}
</template>
</h4>
<slot name="header-extra" :text="text" />
</b-card-header>
<b-card-body v-html="text.content" :class="ellipsis ? 'ellipsis' : 'overflow-auto'" />
<b-card-footer>
<b-badge
v-for="tag in text.tags" :key="tag.id"
variant="dark" pill
>
{{ tag.name }}
</b-badge>
<slot name="footer-extra" v-bind="{ text, toCommaList }" />
</b-card-footer>
</div>
</b-overlay>
</b-card>
</template>
<script>
export default {
name: 'TextCardBase',
props: {
id: { type: Number, required: true },
textData: { type: Object, default: null },
ellipsis: { type: Boolean, default: false }
},
data () {
return {
loading: this.textData === null,
text: this.textData
}
},
methods: {
toCommaList (arr) {
// FIXME TEMP some texts doesn't have authors
try {
return arr.map(({ name }) => name).join(', ')
} catch {
return arr
}
}
},
created () {
if (this.text !== null) return
this.$store.dispatch('GET_TEXT', { id: this.id }).then((text) => {
this.text = text
this.loading = false
})
}
}
</script>
<style lang="scss" scoped>
article {
height: 100%;
}
header {
display: flex;
h4 {
display: flex;
flex-direction: column;
}
}
.ellipsis {
max-height: 10rem;
overflow: hidden;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<template>
<text-card-base v-bind="$attrs" ellipsis>
</text-card-base>
</template>
<script>
import TextCardBase from './TextCardBase'
export default {
name: 'TextMiniCard',
components: {
TextCardBase
}
}
</script>
<style lang="scss" scoped>
</style>
+82
View File
@@ -0,0 +1,82 @@
import { hierarchy } from 'd3-hierarchy'
function flatten (arr, accumulator = []) {
return arr.reduce((acc, child) => {
acc.push(child)
return child.children ? flatten(child.children, acc) : acc
}, accumulator)
}
function getLinked (text) {
const types = ['siblings', 'children', 'parents']
return types.reduce((acc, type) => {
// Handle `null` and `undefined`
return text[type] ? [...acc, ...text[type]] : acc
}, [])
}
export function toSingleManyData (rawData) {
rawData.first = true
const h = hierarchy(rawData, d => getLinked(d))
h.each(node => {
if (node.parent && node.children) {
const id = node.parent.data.id
node.children = node.children.filter(child => child.data.id !== id)
}
})
const nodes = h.descendants()
const links = h.links()
nodes.forEach(node => {
Object.assign(node.data, {
type: node.data.type.toLowerCase(),
class: 'family-' + node.data.families[0].id
})
})
return { nodes, links }
}
export function toManyManyData (rawData) {
rawData.first = true
const h = hierarchy(rawData, d => getLinked(d))
h.each(node => {
if (node.parent && node.children) {
const id = node.parent.data.id
// Remove reference of parent in child's children.
node.children = node.children.filter(child => child.data.id !== id)
}
})
const links = []
const nodes = flatten(h.descendants()).reduce((nodes, node) => {
const sameNode = nodes.find(n => node.data.id === n.data.id)
if (sameNode) {
if (!node.children) return nodes
node.children.forEach(child => {
if (!sameNode.children.find(c => child.data.id === c.data.id)) {
sameNode.children.push(child)
}
})
} else {
if (!node.children) node.children = []
nodes.push(node)
}
return nodes
}, []).map(({ data, children, depth }) => {
if (children) {
children.forEach(child => {
links.push({ source: data.id, target: child.data.id })
})
}
return {
id: data.id,
data: {
...data,
type: data.type.toLowerCase(),
class: 'family-' + data.families[0].id
}
}
})
return { nodes, links }
}
+20 -2
View File
@@ -1,14 +1,32 @@
import Vue from 'vue'
import BootstrapVue from 'bootstrap-vue'
import Meta from 'vue-meta'
import Vue2TouchEvents from 'vue2-touch-events'
import VueMessages from './messages'
import App from './App'
import router from './router'
import store from './store'
import App from './App'
Vue.use(BootstrapVue, {
BButton: { pill: true },
BCardHeader: { headerBgVariant: 'white' },
BCardFooter: { footerBgVariant: 'white' }
})
Vue.use(Meta)
Vue.use(Vue2TouchEvents)
Vue.use(VueMessages)
// Register global components
const requireComponent = require.context('@/components/globals', true, /\.(js|vue)$/i)
// For each matching file name...
requireComponent.keys().forEach((fileName) => {
// Get the component
const component = requireComponent(fileName).default
// Globally register the component
Vue.component(component.name, component)
})
new Vue({
+33
View File
@@ -0,0 +1,33 @@
{
"title": "En françaiS au pluriel",
"sections": {
"home": "Accueil",
"introduction": "Présentation",
"contact": "Contact",
"library": "Bibliothèque",
"kit": "Kit de désapprentissage",
"gallery": "Créations numériques",
"blog": "Blog"
},
"options": {
"display": {
"title": "Gestion de l'affichage",
"choices": {
"tree-map": "Arborescent",
"card-map": "Aléatoire",
"card-list": "Alphabétique"
}
},
"filters": {
"title": "Filtres",
"choices": {
"tags": "Tags",
"strangeness": "Degré d'étrangement"
}
},
"search": {
"title": "Rechercher"
}
},
"siblings": "Textes rebonds"
}
+34
View File
@@ -0,0 +1,34 @@
import messages from './fr.json'
function getMessage (keyedPath, alt) {
let message
try {
message = keyedPath.split('.').reduce((parent, key) => {
return parent[key]
}, messages)
} catch {
// Silence errors like accessing a key on `undefined`.
}
return message === undefined
? alt === undefined ? keyedPath : alt
: message
}
/*
* Allows separtion of static text content from its templates.
*/
export default class VueMessages {
static install (Vue) {
// Adds `this.$t('path.to.string', 'alt')` to every instances.
Vue.prototype.$t = getMessage
// Adds `v-t="'path.to.string'"` directive to every elements.
Vue.directive('t', {
bind (el, { value }) {
el.textContent = getMessage(value)
}
})
}
}
+12
View File
@@ -0,0 +1,12 @@
<template>
<component-debug :component="this" />
</template>
<script>
export default {
name: 'Blog'
}
</script>
<style lang="scss" scoped>
</style>
+12
View File
@@ -0,0 +1,12 @@
<template>
<component-debug :component="this" />
</template>
<script>
export default {
name: 'Contact'
}
</script>
<style lang="scss" scoped>
</style>
+12
View File
@@ -0,0 +1,12 @@
<template>
<component-debug :component="this" />
</template>
<script>
export default {
name: 'Gallery'
}
</script>
<style lang="scss" scoped>
</style>
+3 -16
View File
@@ -1,23 +1,10 @@
<template>
<div class="home">
{{ text }}
</div>
<component-debug :component="this">
</component-debug>
</template>
<script>
export default {
name: 'Home',
data () {
return {
text: null
}
},
created () {
this.$store.dispatch('GET_TEXT', 1).then(({ data }) => {
this.text = data.data.textref
})
}
name: 'Home'
}
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<component-debug :component="this" />
</template>
<script>
export default {
name: 'Introduction'
}
</script>
<style lang="scss" scoped>
</style>
+12
View File
@@ -0,0 +1,12 @@
<template>
<component-debug :component="this" />
</template>
<script>
export default {
name: 'Kit'
}
</script>
<style lang="scss" scoped>
</style>
+90
View File
@@ -0,0 +1,90 @@
<template>
<div class="h-100 position-relative">
<!-- BACKGROUND (mode) -->
<component :is="mode" @open="openText" />
<!-- FOREGROUND (texts) -->
<section v-for="parent in parents" :key="parent.id" class="split-screen">
<text-card
:id="parent.id"
:children="parent.children"
@open="openText" @close="closeText"
/>
<div>
<text-card
v-for="id in parent.children" :key="id"
:id="id"
@close="closeText(parent.id, $event)"
/>
</div>
</section>
</div>
</template>
<script>
import { CardList, CardMap, TreeMap } from './library'
import TextCard from '@/components/text/TextCard'
export default {
name: 'Library',
components: {
CardList,
CardMap,
TreeMap,
TextCard
},
props: {
mode: { type: String, required: true },
texts: { type: Array, required: true }
},
computed: {
parents () {
return this.texts.map(text => {
const [id, ...children] = text
return { id, children }
})
}
},
methods: {
openText (id, childId) {
const parent = this.parents.find(p => p.id === id)
if (parent && (childId === undefined || parent.children.includes(childId))) return
if (!parent) {
this.parents.push({ id, children: childId ? [childId] : [] })
} else if (childId) {
parent.children.push(childId)
}
this.updateQuery(this.parents)
},
closeText (id, childId) {
const parent = this.parents.find(p => p.id === id)
if (!childId) {
this.updateQuery(this.parents.filter(p => p !== parent))
} else {
parent.children = parent.children.filter(childId_ => childId_ !== childId)
this.updateQuery(this.parents)
}
},
updateQuery (parents) {
// Update the route's query (will not reload the page) and let vue determine what changed.
this.$router.push({
query: {
mode: this.mode,
texts: parents.map(parent => [parent.id, ...parent.children])
}
})
}
}
}
</script>
<style lang="scss" scoped>
</style>
+132
View File
@@ -0,0 +1,132 @@
<template>
<b-overlay :show="loading" rounded="sm" class="h-100">
<div class="d-flex flex-column h-100 px-4 py-3">
<div id="maps">
<div>
<h4>Carte avec duplication</h4>
<div class="node-map">
<node-map
v-if="data"
v-bind="mapSingle"
:show-id="showId"
/>
</div>
</div>
<div>
<h4>Carte avec liens multiples</h4>
<div class="node-map">
<node-map
v-if="data"
v-bind="mapMany"
:show-id="showId"
id="map-2"
/>
</div>
</div>
</div>
<b-form class="mt-4">
<b-form-group label="Texte de départ:" label-cols="2" class="mb-2">
<b-form-select
v-model="textId"
@input="query"
:options="textsDepartOptions"
/>
</b-form-group>
<b-form-group label="Profondeur:" label-cols="2" class="mb-2">
<b-form-spinbutton
v-model="depth"
@input="query"
min="0" max="6"
inline
/>
</b-form-group>
<b-form-checkbox v-model="showId" name="check-button" switch>
Afficher les numéros
</b-form-checkbox>
</b-form>
</div>
</b-overlay>
</template>
<script>
import { mapGetters } from 'vuex'
import NodeMap from '@/components/NodeMap'
import { toSingleManyData, toManyManyData } from '@/helpers/d3Data'
export default {
name: 'Home',
components: {
NodeMap
},
data () {
return {
loading: false,
textId: undefined,
showId: true,
depth: 3,
data: null,
mapSingle: { nodes: null, links: null },
mapMany: { nodes: null, links: null }
}
},
computed: {
...mapGetters(['textsDepartOptions'])
},
methods: {
query () {
const { textId: id, depth } = this
this.data = null
this.loading = true
this.$store.dispatch('GET_TREE_WITH_DEPTH', { id, depth }).then((data) => {
this.mapSingle = toSingleManyData(data)
this.mapMany = toManyManyData(data)
this.data = data
this.loading = false
})
}
},
created () {
this.loading = true
this.$store.dispatch('GET_TEXTS_DEPART').then(textsDepart => {
this.textId = textsDepart[0].id
})
}
}
</script>
<style lang="scss" scoped>
#maps {
width: 100%;
height: 100%;
display: flex;
& > div {
flex-basis: 50%;
display: flex;
flex-direction: column;
& + div .node-map {
border-left: none;
}
}
.node-map {
height: 100%;
border: 1px solid grey;
}
}
.col > * {
width: auto;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<template>
<div class="h-100 position-absolute overflow-auto">
<div v-for="(parents, char) in orderedParents" :key="char">
<h3>{{ char }}</h3>
<b-card-group deck>
<text-mini-card
v-for="parent in parents" :key="parent.id"
:id="parent.id"
:text-data="parent"
@click.native="$emit('open', parent.id)"
/>
</b-card-group>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import TextMiniCard from '@/components/text/TextMiniCard'
export default {
name: 'CardList',
components: {
TextMiniCard
},
computed: {
...mapGetters(['orderedParents'])
},
created () {
this.$store.dispatch('GET_TEXTS_DEPART')
}
}
</script>
<style lang="scss" scoped>
.card-deck {
.card {
flex-basis: auto;
@include media-breakpoint-up(md) {
flex-basis: 50%;
max-width: calc(50% - 30px);
}
@include media-breakpoint-up(lg) {
flex-basis: 33%;
max-width: calc(33.3% - 30px);
}
}
}
</style>
+12
View File
@@ -0,0 +1,12 @@
<template>
<component-debug :component="this" />
</template>
<script>
export default {
name: 'CardMap'
}
</script>
<style lang="scss" scoped>
</style>
+94
View File
@@ -0,0 +1,94 @@
<template>
<div v-if="show" class="d-flex justify-content-between">
<b-form-group
:label="$t('options.display.title')"
v-slot="{ ariaDescribedby }"
>
<b-form-radio-group
id="mode-select"
v-model="currentMode" :options="modes"
name="mode-select" :aria-describedby="ariaDescribedby" buttons
size="sm"
/>
</b-form-group>
<b-form-group
v-if="currentMode !== 'tree-map'"
:label="$t('options.filters.title')"
>
<b-form-group
:label="$t('options.filters.choices.tags')"
label-for="tags-select"
>
<multiple-tags-select
id="tags-select" :button-text="$t('options.filters.choices.tags')"
v-model="selectedTags" :options="tags"
/>
</b-form-group>
<b-form-group
:label="$t('options.filters.choices.strangeness')"
label-for="strangeness-input"
>
<b-form-input
id="strangeness-input"
type="range" min="0" max="5"
v-model="strangeness"
/>
</b-form-group>
</b-form-group>
<b-form-group
:label="$t('options.search.title')"
label-for="search-input"
>
<b-input-group size="sm" append="search-icon">
<b-form-input v-model="search" id="search-input" trim />
</b-input-group>
</b-form-group>
</div>
</template>
<script>
import MultipleTagsSelect from '@/components/formItems/MultipleTagsSelect'
export default {
name: 'LibraryOptions',
components: {
MultipleTagsSelect
},
props: {
show: { type: Boolean, required: true },
mode: { type: String, required: true },
tags: { type: Array, default: () => ([]) }
},
data () {
return {
modes: [
{ text: this.$t('options.display.choices.tree-map'), value: 'tree-map' },
{ text: this.$t('options.display.choices.card-map'), value: 'card-map' },
{ text: this.$t('options.display.choices.card-list'), value: 'card-list' }
],
currentMode: this.mode,
selectedTags: [],
strangeness: 0,
search: ''
}
},
watch: {
currentMode (mode) {
this.$router.push({ query: { mode } })
}
}
}
</script>
<style lang="scss" scoped>
::v-deep fieldset > div {
display: flex;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<template>
<b-overlay :show="loading" class="h-100 position-absolute">
<node-map
v-if="!loading"
v-bind="mapData"
:show-id="true"
v-on="$listeners"
/>
</b-overlay>
</template>
<script>
import NodeMap from '@/components/NodeMap'
import { toManyManyData } from '@/helpers/d3Data'
export default {
name: 'TreeMap',
components: {
NodeMap
},
props: {
id: { type: Number, default: 1 }
},
data () {
return {
loading: true,
depth: 3,
mapData: { nodes: null, links: null }
}
},
created () {
const { id, depth } = this
this.$store.dispatch('GET_TREE_WITH_DEPTH', { id, depth }).then((data) => {
this.mapData = toManyManyData(data)
this.loading = false
})
}
}
</script>
<style lang="scss" scoped>
</style>
+3
View File
@@ -0,0 +1,3 @@
export { default as CardList } from './CardList'
export { default as CardMap } from './CardMap'
export { default as TreeMap } from './TreeMap'
+2 -15
View File
@@ -1,23 +1,10 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../pages/Home.vue'
import routes from './routes'
Vue.use(VueRouter)
const routes = [
{
name: 'home',
path: '/',
component: Home
},
{
name: 'notfound',
path: '/404',
alias: '*',
component: () => import(/* webpackChunkName: "404" */ '../pages/NotFound.vue')
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
+78
View File
@@ -0,0 +1,78 @@
import Home from '@/pages/Home'
export default [
{
name: 'home',
path: '/',
component: Home
},
{
name: 'introduction',
path: '/intro',
component: () => import(/* webpackChunkName: "intro" */ '@/pages/Introduction')
},
{
name: 'contact',
path: '/contact',
component: () => import(/* webpackChunkName: "contact" */ '@/pages/Contact')
},
{
name: 'library',
path: '/library',
components: {
default: () => import(/* webpackChunkName: "library" */ '@/pages/Library'),
options: () => import(/* webpackChunkName: "library" */ '@/pages/library/LibraryOptions')
},
props: {
default: ({ query }) => {
let { mode = 'tree-map', texts = [] } = query
if (typeof texts === 'string') texts = [texts]
// In case of a reload or direct link, vue-router doesn't turn the query string into an array.
if (texts && texts.length && typeof texts[0] === 'string') {
texts = texts.map(text => text.split(',').map(id => parseInt(id)))
}
return { mode, texts }
},
options: ({ query }) => ({
show: !('texts' in query && query.texts.length),
mode: query.mode || 'tree-map'
})
}
},
{
name: 'kit',
path: '/kit',
component: () => import(/* webpackChunkName: "kit" */ '@/pages/Kit')
},
{
name: 'gallery',
path: '/gallery',
component: () => import(/* webpackChunkName: "gallery" */ '@/pages/Gallery')
},
{
name: 'blog',
path: '/blog',
component: () => import(/* webpackChunkName: "blog" */ '@/pages/Blog')
},
// TEMP
{
name: 'map',
path: '/map',
component: () => import(/* webpackChunkName: "test" */ '@/pages/Map')
},
{
name: 'notfound',
path: '/404',
alias: '*',
component: () => import(/* webpackChunkName: "404" */ '@/pages/NotFound.vue')
}
]
+2 -16
View File
@@ -1,27 +1,13 @@
import Vue from 'vue'
import Vuex from 'vuex'
import api from '@/api'
import { print } from 'graphql/language/printer'
import TextRef from '@/api/queries/TextRef.gql'
import texts from './modules/texts'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
},
state: {
},
mutations: {
},
actions: {
'GET_TEXT' ({ state }, id) {
return api.post('', { query: print(TextRef), variables: { id } })
}
texts
}
})
-24
View File
@@ -1,24 +0,0 @@
export default {
namespaced: true,
// initial state
state: {
// items: [],
},
// getters
getters: {
},
// mutations
mutations: {
},
// actions
actions: {
},
// methods
methods: {
}
}
+80
View File
@@ -0,0 +1,80 @@
import api from '@/api'
import { print } from 'graphql/language/printer'
import {
TextsDepart, TextCard, TextdepartRecursive
} from '@/api/queries'
import TextdepartRecursiveWithDepth from '@/api/queries/TextdepartRecursiveWithDepth.gql'
export default {
state: {
textsDepart: undefined
},
mutations: {
'SET_TEXTS_DEPART' (state, texts) {
state.textsDepart = texts
}
},
actions: {
'GET_TEXTS_DEPART' ({ state, commit }) {
return api.post('', { query: print(TextsDepart) }).then(({ data }) => {
commit('SET_TEXTS_DEPART', data.data.textsdepart)
return state.textsDepart
})
},
'GET_TEXT' (store, { id }) {
return api.post('', { query: print(TextCard), variables: { id } })
.then(data => (data.data.data.text))
},
'GET_TREE' (store, id) {
return api.post('', { query: print(TextdepartRecursive), variables: { id } })
.then(({ data }) => (data.data.textref))
},
'GET_TREE_WITH_DEPTH' (store, { id, depth }) {
const baseQuery = print(TextdepartRecursiveWithDepth)
function formatQuery (str, depth) {
if (depth > 0) {
return formatQuery(
str.replace('INPUT', '...TextrefTreeFields\nsiblings: text_en_rebond {\nINPUT\n}'),
--depth
)
} else {
return str.replace('INPUT', '...TextrefTreeFields')
}
}
return api.post('', { query: formatQuery(baseQuery, depth), variables: { id } })
.then(({ data }) => (data.data.textref))
}
},
getters: {
textsDepartOptions: state => {
if (!state.textsDepart) return undefined
return state.textsDepart.map(({ id, title }) => ({
value: id,
text: `(${id}) ${title}`
}))
},
orderedParents: state => {
// FIXME duplicates references if multiple authors ?
if (!state.textsDepart) return undefined
return state.textsDepart.sort((a, b) => {
if (!b.authors) return -1
if (!a.authors) return +1
if (a.authors[0].name < b.authors[0].name) return -1
if (a.authors[0].name > b.authors[0].name) return 1
return 0
}).reduce((dict, text) => {
const firstChar = text.authors ? text.authors[0].name[0] : '&'
if (!(firstChar in dict)) dict[firstChar] = []
dict[firstChar].push(text)
return dict
}, {})
}
}
}
+7 -1
View File
@@ -8,7 +8,13 @@ module.exports = {
.loader('graphql-tag/loader')
.end()
},
css: {
loaderOptions: {
sass: {
prependData: '@import "@/assets/scss/globals.scss";'
}
}
},
publicPath: '/',
devServer: {
clientLogLevel: 'warning',