"alpinejs": "^3.4.2",
"autoprefixer": "^10.3.7",
"axios": "^0.21",
+ "isotope-layout": "^3.0.6",
"laravel-mix": "^6.0.6",
"lodash": "^4.17.19",
"postcss": "^8.3.10",
"minimalistic-assert": "^1.0.0"
}
},
+ "node_modules/desandro-matches-selector": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/desandro-matches-selector/-/desandro-matches-selector-2.0.2.tgz",
+ "integrity": "sha1-cXvu1NwT59jzdi9wem1YpndCGOE=",
+ "dev": true
+ },
"node_modules/destroy": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
"node": ">= 0.6"
}
},
+ "node_modules/ev-emitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ev-emitter/-/ev-emitter-1.1.1.tgz",
+ "integrity": "sha512-ipiDYhdQSCZ4hSbX4rMW+XzNKMD1prg/sTvoVmSLkuQ1MVlwjJQQA+sW8tMYR3BLUr9KjodFV4pvzunvRhd33Q==",
+ "dev": true
+ },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"node": ">=8"
}
},
+ "node_modules/fizzy-ui-utils": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/fizzy-ui-utils/-/fizzy-ui-utils-2.0.7.tgz",
+ "integrity": "sha512-CZXDVXQ1If3/r8s0T+v+qVeMshhfcuq0rqIFgJnrtd+Bu8GmDmqMjntjUePypVtjHXKJ6V4sw9zeyox34n9aCg==",
+ "dev": true,
+ "dependencies": {
+ "desandro-matches-selector": "^2.0.0"
+ }
+ },
"node_modules/follow-redirects": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-size": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/get-size/-/get-size-2.0.3.tgz",
+ "integrity": "sha512-lXNzT/h/dTjTxRbm9BXb+SGxxzkm97h/PCIKtlN/CBCxxmkkIVV21udumMS93MuVTDX583gqc94v3RjuHmI+2Q==",
+ "dev": true
+ },
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"node": ">=0.10.0"
}
},
+ "node_modules/isotope-layout": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/isotope-layout/-/isotope-layout-3.0.6.tgz",
+ "integrity": "sha512-z2ZKablhocXhoNyWwzJPFd7u7FWbYbVJA51Nvsqsod8jH2ExGc1SwDsSWKE54e3PhXzqf2yZPhFSq/c2MR1arw==",
+ "dev": true,
+ "dependencies": {
+ "desandro-matches-selector": "^2.0.0",
+ "fizzy-ui-utils": "^2.0.4",
+ "get-size": "^2.0.0",
+ "masonry-layout": "^4.1.0",
+ "outlayer": "^2.1.0"
+ }
+ },
"node_modules/jest-worker": {
"version": "27.3.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.3.1.tgz",
"semver": "bin/semver.js"
}
},
+ "node_modules/masonry-layout": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/masonry-layout/-/masonry-layout-4.2.2.tgz",
+ "integrity": "sha512-iGtAlrpHNyxaR19CvKC3npnEcAwszXoyJiI8ARV2ePi7fmYhIud25MHK8Zx4P0LCC4d3TNO9+rFa1KoK1OEOaA==",
+ "dev": true,
+ "dependencies": {
+ "get-size": "^2.0.2",
+ "outlayer": "^2.1.0"
+ }
+ },
"node_modules/md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=",
"dev": true
},
+ "node_modules/outlayer": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/outlayer/-/outlayer-2.1.1.tgz",
+ "integrity": "sha1-KYY7beEOpdrf/8rfoNcokHOH6aI=",
+ "dev": true,
+ "dependencies": {
+ "ev-emitter": "^1.0.0",
+ "fizzy-ui-utils": "^2.0.0",
+ "get-size": "^2.0.2"
+ }
+ },
"node_modules/p-event": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz",
"minimalistic-assert": "^1.0.0"
}
},
+ "desandro-matches-selector": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/desandro-matches-selector/-/desandro-matches-selector-2.0.2.tgz",
+ "integrity": "sha1-cXvu1NwT59jzdi9wem1YpndCGOE=",
+ "dev": true
+ },
"destroy": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
"dev": true
},
+ "ev-emitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ev-emitter/-/ev-emitter-1.1.1.tgz",
+ "integrity": "sha512-ipiDYhdQSCZ4hSbX4rMW+XzNKMD1prg/sTvoVmSLkuQ1MVlwjJQQA+sW8tMYR3BLUr9KjodFV4pvzunvRhd33Q==",
+ "dev": true
+ },
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"path-exists": "^4.0.0"
}
},
+ "fizzy-ui-utils": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/fizzy-ui-utils/-/fizzy-ui-utils-2.0.7.tgz",
+ "integrity": "sha512-CZXDVXQ1If3/r8s0T+v+qVeMshhfcuq0rqIFgJnrtd+Bu8GmDmqMjntjUePypVtjHXKJ6V4sw9zeyox34n9aCg==",
+ "dev": true,
+ "requires": {
+ "desandro-matches-selector": "^2.0.0"
+ }
+ },
"follow-redirects": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"has-symbols": "^1.0.1"
}
},
+ "get-size": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/get-size/-/get-size-2.0.3.tgz",
+ "integrity": "sha512-lXNzT/h/dTjTxRbm9BXb+SGxxzkm97h/PCIKtlN/CBCxxmkkIVV21udumMS93MuVTDX583gqc94v3RjuHmI+2Q==",
+ "dev": true
+ },
"get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=",
"dev": true
},
+ "isotope-layout": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/isotope-layout/-/isotope-layout-3.0.6.tgz",
+ "integrity": "sha512-z2ZKablhocXhoNyWwzJPFd7u7FWbYbVJA51Nvsqsod8jH2ExGc1SwDsSWKE54e3PhXzqf2yZPhFSq/c2MR1arw==",
+ "dev": true,
+ "requires": {
+ "desandro-matches-selector": "^2.0.0",
+ "fizzy-ui-utils": "^2.0.4",
+ "get-size": "^2.0.0",
+ "masonry-layout": "^4.1.0",
+ "outlayer": "^2.1.0"
+ }
+ },
"jest-worker": {
"version": "27.3.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.3.1.tgz",
}
}
},
+ "masonry-layout": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/masonry-layout/-/masonry-layout-4.2.2.tgz",
+ "integrity": "sha512-iGtAlrpHNyxaR19CvKC3npnEcAwszXoyJiI8ARV2ePi7fmYhIud25MHK8Zx4P0LCC4d3TNO9+rFa1KoK1OEOaA==",
+ "dev": true,
+ "requires": {
+ "get-size": "^2.0.2",
+ "outlayer": "^2.1.0"
+ }
+ },
"md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=",
"dev": true
},
+ "outlayer": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/outlayer/-/outlayer-2.1.1.tgz",
+ "integrity": "sha1-KYY7beEOpdrf/8rfoNcokHOH6aI=",
+ "dev": true,
+ "requires": {
+ "ev-emitter": "^1.0.0",
+ "fizzy-ui-utils": "^2.0.0",
+ "get-size": "^2.0.2"
+ }
+ },
"p-event": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz",
"alpinejs": "^3.4.2",
"autoprefixer": "^10.3.7",
"axios": "^0.21",
+ "isotope-layout": "^3.0.6",
"laravel-mix": "^6.0.6",
"lodash": "^4.17.19",
"postcss": "^8.3.10",
--- /dev/null
+// AlpineJS setup - https://alpinejs.dev/
+import Alpine from 'alpinejs';
+window.Alpine = Alpine;
+
+// Plugins
+import intersect from '@alpinejs/intersect';
+Alpine.plugin(intersect);
+
+// Components
+import media_library from './media-library';
+Alpine.data('media_library', media_library);
+
+Alpine.start();
-// Add AlpineJS - https://alpinejs.dev/
-import Alpine from 'alpinejs';
-window.Alpine = Alpine;
-
-import intersect from '@alpinejs/intersect';
-Alpine.plugin(intersect)
-
-Alpine.start();
-
+require('./alpine');
window._ = require('lodash');
--- /dev/null
+// Media Library functionality
+export default (options = {}) => ({
+ filters: options.filters || {}, // Filters JSON array is passed in from HTML
+ gridSelector: options.gridSelector || false,
+ filtersOpen: false,
+ activeTypeFilters: [],
+ activeThemeFilters: [],
+ isotope: {},
+
+ get themeFilterList() {
+ // Convert active theme filters array into class string for Isotope
+ // There are two separate groups of filters: media types and themes
+ // Within each group, Isotope should match any filter ("OR") but
+ // between the two groups, it should match both ("AND")
+ // To achieve this, we combine the two lists...
+
+ // Todo: see https://stackoverflow.com/a/20490439 for an alternative solution that supports multiple sets of filters
+
+ let list = [];
+ let types = this.activeTypeFilters;
+ let themes = this.activeThemeFilters;
+
+ // If no media types are defined, just return the concatenated theme list (even if it might also be empty)
+ if (types.length === 0) {
+ return themes.map(filter => `.${filter}`).join();
+ }
+
+ if (themes.length === 0) {
+ return types.map(filter => `.${filter}`).join();
+ }
+
+ // If we get to here, both lists are populated, so combine them...
+ types.forEach(function(type) {
+ themes.forEach(function(theme) {
+ list.push(`.${type}.${theme}`);
+ });
+ });
+
+ return list.join();
+ },
+
+ init() {
+ if (!this.gridSelector) {
+ console.warn('Error: gridSelector not defined for media grid');
+ return false;
+ }
+
+ // Initialise Isotope
+ this.isotope = new Isotope(this.gridSelector, {
+ layoutMode: 'fitRows',
+ percentPosition: true,
+ });
+
+ // Update Isotope whenever active filters change
+ this.$watch('activeTypeFilters', () => this.applyFilters());
+ this.$watch('activeThemeFilters', () => this.applyFilters());
+ },
+
+ applyFilters() {
+ console.log('applying filters', this.themeFilterList)
+ this.isotope.arrange({ filter: this.themeFilterList })
+ },
+
+ removeTypeFilter(filterID) {
+ // Remove the filter from the array
+ this.activeTypeFilters = this.activeTypeFilters.filter(item => item !== filterID)
+ },
+
+ removeThemeFilter(filterID) {
+ // Remove the filter from the array
+ this.activeThemeFilters = this.activeThemeFilters.filter(item => item !== filterID)
+ },
+
+});
--- /dev/null
+{{-- Duration: converts seconds to minutes + seconds with zero padding --}}
+@php
+ $duration = intval((string) $slot); // Need to convert $slot from Illuminate\Support\HtmlString to string before using intval()
+ $seconds = $duration % 60; // Fractional part of any full minutes
+ $minutes = floor($duration / 60); // Floor the value because we already captured the extra seconds
+@endphp
+
+<span {{ $attributes }}>
+ {{ str_pad($minutes, 2, STR_PAD_LEFT) }}’{{ str_pad($seconds, 2, STR_PAD_LEFT) }}
+</span>
[
'title' => "Qu’est ce que la gouvernance ?",
'type' => 'video',
+ 'duration' => '78',
'image' => 'https://odl.paris.cubedesigners.com/storage/46/conversions/VIDEO2-poster.jpg',
'file' => '#',
'theme' => [
[
'title' => 'Les outils de communication',
'type' => 'video',
+ 'duration' => '192',
'image' => 'https://odl.paris.cubedesigners.com/storage/4/conversions/Big-rock-at-the-beach-poster.jpg',
'file' => '#',
'theme' => [
[
'title' => "Une organisation à plusieurs niveaux",
'type' => 'video',
+ 'duration' => '322',
'image' => 'https://odl.paris.cubedesigners.com/storage/46/conversions/VIDEO2-poster.jpg',
'file' => '#',
'theme' => [
],
[
'title' => 'Système d’information',
- 'type' => 'video',
+ 'type' => 'audio',
+ 'duration' => '987',
'image' => 'https://odl.paris.cubedesigners.com/storage/4/conversions/Big-rock-at-the-beach-poster.jpg',
'file' => '#',
'theme' => [
[
'title' => "Qu’est ce que la gouvernance ?",
'type' => 'video',
+ 'duration' => '414',
'image' => 'https://odl.paris.cubedesigners.com/storage/46/conversions/VIDEO2-poster.jpg',
'file' => '#',
'theme' => [
],
[
'title' => 'Les outils de communication',
- 'type' => 'video',
+ 'type' => 'audio',
+ 'duration' => '45',
'image' => 'https://odl.paris.cubedesigners.com/storage/4/conversions/Big-rock-at-the-beach-poster.jpg',
'file' => '#',
'theme' => [
});
$filters = array_merge($media_filters->toArray(), $theme_filters->toArray());
- // TODO: Implement Isotope JS for class-based filtering
@endphp
- <div x-data="{ filters: {{ json_encode($filters) }}, filtersOpen: false, activeFilters: [] }" x-effect="console.log(activeFilters)">
+ {{-- Media Library widget --}}
+ {{-- See alpine.js for setup and media-library.js for code --}}
+ <div x-data="media_library({ gridSelector: '.media-grid', filters: {{ json_encode($filters) }} })">
<h1 class="uppercase">Médiathèque</h1>
{{-- FILTERS --}}
<div class="mt-10 space-x-2 space-y-2">
+
+ {{-- FILTER INTERFACE BUTTON --}}
<a @click.prevent="filtersOpen = true"
href="#"
class="inline-block py-4 px-6 rounded-full
bg-black hover:bg-blue
+ mt-2 {{-- Margin top added here so layout doesn't shift when last filter is removed --}}
+ border border-black hover:border-blue {{-- Needs border so it matches the height of filter buttons --}}
text-white font-secondary font-medium leading-none">
Filtrer
</a>
- {{-- ACTIVE FILTERS (JS) --}}
- <template x-for="activeFilter in activeFilters">
- <div class="relative inline-flex py-4 pl-4 pr-12 leading-none rounded-full border border-grey-200 text-black">
- <span x-text="filters[activeFilter]" class="whitespace-nowrap"></span>
+ {{-- ACTIVE [MEDIA TYPE] FILTERS (JS) --}}
+ {{-- Media types must be filtered separately from themes --}}
+ <template x-for="activeTypeFilter in activeTypeFilters">
+ <div class="relative inline-flex
+ py-4 pl-4 pr-12
+ rounded-full border border-grey-200
+ leading-none
+ font-secondary font-medium text-black">
+ <span x-text="filters[activeTypeFilter]" class="whitespace-nowrap"></span>
{{-- REMOVE (X) ICON --}}
<a href="#"
- @click.prevent="activeFilters = activeFilters.filter(item => item !== activeFilter)"
+ @click.prevent="removeTypeFilter(activeTypeFilter)"
+ class="absolute w-7 h-7 right-2 top-1/2 transform -translate-y-1/2
+ flex items-center justify-center
+ rounded-full bg-grey-200 text-current">
+ <svg class="stroke-current" xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 10.828 10.828">
+ <g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
+ <path d="m1.414 9.414 8-8"/>
+ <path data-name="Path 46" d="m1.414 1.414 8 8"/>
+ </g>
+ </svg>
+ </a>
+ </div>
+ </template>
+
+ {{-- ACTIVE [THEME] FILTERS (JS) --}}
+ <template x-for="activeThemeFilter in activeThemeFilters">
+ <div class="relative inline-flex
+ py-4 pl-4 pr-12
+ rounded-full border border-grey-200
+ leading-none
+ font-secondary font-medium text-black">
+ <span x-text="filters[activeThemeFilter]" class="whitespace-nowrap"></span>
+ {{-- REMOVE (X) ICON --}}
+ <a href="#"
+ @click.prevent="removeThemeFilter(activeThemeFilter)"
class="absolute w-7 h-7 right-2 top-1/2 transform -translate-y-1/2
flex items-center justify-center
rounded-full bg-grey-200 text-current">
</div>
{{-- MEDIA LIBRARY GRID --}}
- <div class="mt-10 grid grid-cols-4 gap-x-5 gap-y-16">
+ {{-- Negative margins applied here to offset margins used in Isotope grid --}}
+ <div class="media-grid mt-10 -mb-16 -mx-2.5">
@foreach ($media as $item)
- <div class="media-item media-{{ $item['type'] }} theme-{{ $item['theme']['id'] }}">
+ <div class="media-item
+ {{-- Width is 25% minus the gutters (2 * 0.625rem that comes from mx-2.5) --}}
+ float-left w-[calc(25%-1.25rem)] mx-2.5 mb-16
+ media-{{ $item['type'] }}
+ theme-{{ $item['theme']['id'] }}">
<div class="media-item-image relative bg-cover bg-no-repeat rounded-md pb-[56.25%]" style="background-image:url({{ $item['image'] }})">
- {{-- TODO: play button --}}
- {{-- TODO: media length --}}
+
+ {{-- Play Icon --}}
+ <svg class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
+ xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 59.999 59.999">
+ <g fill="#0725e2">
+ <path d="M0 29.999a30 30 0 1 0 30-30 30 30 0 0 0-30 30Z"/>
+ <path d="m26.5 38.5 12-8-12-8Z" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
+ </g>
+ </svg>
+
+ <x-duration class="absolute bottom-2 right-2 font-secondary text-white">{{ $item['duration'] }}</x-duration>
</div>
+ {{-- THEME LABEL --}}
<div class="mt-2.5 font-secondary font-medium text-xs leading-none" style="color:{{ $item['theme']['color'] }}">
- {{ $item['theme']['title'] }}
+ {{ $item['theme']['title'] }} [{{ strtoupper($item['type']) }}]
</div>
<div class="mt-1.5 font-semibold leading-snug">
{{ $item['title'] }}
@endforeach
</div>
- {{-- FILTERING INTERFACE --}}
+ {{-- FILTER SELECTION INTERFACE (full screen overlay) --}}
<div class="filters-overlay
fixed top-0 left-0 w-screen h-screen
flex items-center
<label>
<input type="checkbox"
value="media-{{ $media_type_id }}"
- x-model="activeFilters"
+ x-model="activeTypeFilters"
class="peer sr-only">
<span class="inline-block cursor-pointer
font-secondary font-medium
<label>
<input type="checkbox"
value="theme-{{ $theme_id }}"
- x-model="activeFilters"
+ x-model="activeThemeFilters"
class="peer sr-only">
<span class="inline-block cursor-pointer
font-secondary font-medium
</div>
-
-
@endsection
+
+@push('before_scripts')
+ <script src="{{ asset('js/isotope.js') }}"></script>
+@endpush
</ul>
</div>
+ @stack('before_scripts')
<script src="{{ asset('js/app.js') }}"></script>
+ @stack('after_scripts')
</body>
</html>
const mix = require('laravel-mix');
-/*
- |--------------------------------------------------------------------------
- | Mix Asset Management
- |--------------------------------------------------------------------------
- |
- | Mix provides a clean, fluent API for defining some Webpack build steps
- | for your Laravel applications. By default, we are compiling the CSS
- | file for the application as well as bundling up all the JS files.
- |
- */
mix.js('resources/js/app.js', 'public/js')
- .postCss('resources/css/app.css', 'public/css', [
+ .postCss('resources/css/app.css', 'public/css', [
require('postcss-import'), // Enables build-time imports
require("tailwindcss"),
]);
+
+// Copy pre-compiled Isotope JS package
+mix.copy('node_modules/isotope-layout/dist/isotope.pkgd.min.js', 'public/js/isotope.js');