mirror of
https://github.com/ThatGuySam/doesitarm.git
synced 2026-05-18 06:44:46 -07:00
Merge branch 'develop'
This commit is contained in:
commit
f94ab19e18
28 changed files with 2231 additions and 110 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -86,5 +86,6 @@ dist
|
|||
/README-temp.md
|
||||
/static/game-list.json
|
||||
/static/homebrew-list.json
|
||||
.DS_Store
|
||||
/static/video-list.json
|
||||
/commits-data.json
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -61,6 +61,26 @@
|
|||
* @import "utilities/skew-transforms";
|
||||
*/
|
||||
|
||||
|
||||
.ease {
|
||||
transition-property: all;
|
||||
transition-duration: 400ms;
|
||||
/* easeInOutQuart */
|
||||
/* https://easings.net/en#easeInOutQuart */
|
||||
transition-timing-function: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
}
|
||||
|
||||
.lazyload,
|
||||
.lazyloading {
|
||||
opacity: 0;
|
||||
}
|
||||
.lazyloaded {
|
||||
@apply ease;
|
||||
transition-property: opacity;
|
||||
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.neumorphic-shadow,
|
||||
.hover\:neumorphic-shadow:hover {
|
||||
/* box-shadow: -0.25rem -0.25rem 0.5rem rgba(255, 255, 255, 0.07); */
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
:href="href"
|
||||
:target="target"
|
||||
:rel="rel"
|
||||
:class="classlist"
|
||||
role="button"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-transparent leading-5 font-medium rounded-md text-white bg-darker neumorphic-shadow hover:bg-indigo-400 focus:outline-none focus:shadow-outline-indigo focus:border-indigo-600 active:bg-indigo-600 transition duration-150 ease-in-out"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
|
|
@ -23,6 +23,10 @@ export default {
|
|||
target: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
classGroups: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -30,6 +34,30 @@ export default {
|
|||
if (this.href.charAt(0) === '/') return null
|
||||
|
||||
return 'noopener'
|
||||
},
|
||||
classlist () {
|
||||
const defaultClassGroups = {
|
||||
general: 'relative inline-flex items-center rounded-md px-4 py-2',
|
||||
font: 'leading-5 font-medium',
|
||||
text: 'text-white',
|
||||
border: 'border border-transparent focus:outline-none focus:border-indigo-600',
|
||||
shadow: 'neumorphic-shadow focus:shadow-outline-indigo',
|
||||
bg: 'bg-darker hover:bg-indigo-400 active:bg-indigo-600',
|
||||
transition: 'transition duration-150 ease-in-out'
|
||||
}
|
||||
|
||||
const mergedClassGroups = {
|
||||
...defaultClassGroups,
|
||||
...this.classGroups
|
||||
}
|
||||
|
||||
// if (this.isFocused) {
|
||||
// delete mergedClassGroups.blur
|
||||
// } else {
|
||||
// delete mergedClassGroups.focus
|
||||
// }
|
||||
|
||||
return Object.values(mergedClassGroups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@ export default {
|
|||
label: 'Categories',
|
||||
url: '/categories',
|
||||
},
|
||||
{
|
||||
label: 'Benchmarks',
|
||||
url: '/benchmarks',
|
||||
},
|
||||
{
|
||||
label: 'Homebrew',
|
||||
url: '/kind/homebrew',
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@
|
|||
<!-- app.endpoint: {{ app.endpoint }} -->
|
||||
<a
|
||||
:href="getAppEndpoint(app)"
|
||||
class="flex flex-col justify-center inset-x-0 hover:bg-darkest border-2 border-white border-opacity-0 hover:border-opacity-50 focus:outline-none focus:bg-gray-50 duration-300 ease-in-out rounded-lg space-y-2 -mx-5 pl-5 md:pl-20 pr-6 md:pr-64 py-6 "
|
||||
class="flex flex-col justify-center inset-x-0 hover:bg-darkest border-2 border-white border-opacity-0 hover:border-opacity-50 focus:outline-none focus:bg-gray-50 duration-300 ease-in-out rounded-lg space-y-2 -mx-5 pl-5 md:pl-20 pr-6 md:pr-64 py-5"
|
||||
style="transition-property: border;"
|
||||
>
|
||||
<template v-if="seenItems[app.slug] === false && hasStartedAnyQuery === false">
|
||||
|
|
@ -118,48 +118,71 @@
|
|||
</small>
|
||||
</client-only>
|
||||
|
||||
<client-only>
|
||||
<svg
|
||||
class="absolute right-0 h-5 w-5 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<use href="#chevron-right" />
|
||||
</svg>
|
||||
</client-only>
|
||||
|
||||
<!-- app.endpoint: {{ app.endpoint }} -->
|
||||
</template>
|
||||
|
||||
</a>
|
||||
|
||||
<!-- <client-only v-if="seenItems[app.slug] || hasStartedAnyQuery">
|
||||
<div
|
||||
class="search-item-options relative md:absolute md:inset-0 w-full pointer-events-none"
|
||||
>
|
||||
|
||||
<div class="search-item-options-container h-full flex justify-center md:justify-end items-center pb-4 md:py-4 md:px-12">
|
||||
<div
|
||||
class="search-item-options relative md:absolute md:inset-0 w-full pointer-events-none"
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="!app.endpoint.includes('/game/')"
|
||||
class="subscribe space-y-6 sm:space-x-6"
|
||||
>
|
||||
<EmailSubscribe
|
||||
:app-name="app.name"
|
||||
:input-class-groups="{
|
||||
shadow: 'hover:neumorphic-shadow',
|
||||
bg: '',
|
||||
focus: 'bg-transparent neumorphic-shadow pl-8',
|
||||
blur: 'placeholder-white text-center border border-transparent bg-transparent opacity-50 hover:opacity-100 px-3',
|
||||
}"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-item-options-container h-full flex justify-center md:justify-end items-center pb-4 md:py-4 md:px-4">
|
||||
|
||||
</div>
|
||||
<LinkButton
|
||||
v-for="link in getSearchLinks(app)"
|
||||
:key="`${app.slug}-${link.label.toLowerCase()}`"
|
||||
:href="link.href"
|
||||
:class="[
|
||||
'px-3 py-2 rounded-md text-sm pointer-events-auto focus:outline-none focus:text-white focus:bg-gray-700 transition duration-150 ease-in-out',
|
||||
'text-gray-300 hover:bg-darker hover:neumorphic-shadow'
|
||||
]"
|
||||
:class-groups="{
|
||||
// general: 'relative inline-flex items-center rounded-md px-4 py-2',
|
||||
// font: 'leading-5 font-medium',
|
||||
// text: 'text-white',
|
||||
// border: 'border border-transparent focus:outline-none focus:border-indigo-600',
|
||||
shadow: 'hover:neumorphic-shadow',
|
||||
bg: 'hover:bg-darker',
|
||||
// transition: 'transition duration-150 ease-in-out'
|
||||
}"
|
||||
>
|
||||
{{ link.label }}
|
||||
</LinkButton>
|
||||
|
||||
<LinkButton
|
||||
:href="getAppEndpoint(app)"
|
||||
:class="[
|
||||
'px-3 py-2 rounded-md text-sm pointer-events-auto focus:outline-none focus:text-white focus:bg-gray-700 transition duration-150 ease-in-out',
|
||||
'text-gray-300 hover:bg-darker hover:neumorphic-shadow'
|
||||
]"
|
||||
:class-groups="{
|
||||
// general: 'relative inline-flex items-center rounded-md px-4 py-2',
|
||||
// font: 'leading-5 font-medium',
|
||||
// text: 'text-white',
|
||||
// border: 'border border-transparent focus:outline-none focus:border-indigo-600',
|
||||
shadow: 'hover:neumorphic-shadow',
|
||||
bg: 'hover:bg-darker',
|
||||
// transition: 'transition duration-150 ease-in-out'
|
||||
}"
|
||||
>
|
||||
<span>Details</span>
|
||||
<client-only>
|
||||
<svg
|
||||
class="h-5 w-5 -mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<use href="#chevron-right" />
|
||||
</svg>
|
||||
</client-only>
|
||||
</LinkButton>
|
||||
|
||||
</div>
|
||||
</client-only> -->
|
||||
|
||||
</div>
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
|
@ -174,7 +197,7 @@ import { getAppCategory } from '~/helpers/categories.js'
|
|||
import { getAppEndpoint } from '~/helpers/app-derived.js'
|
||||
// import appList from '~/static/app-list.json'
|
||||
|
||||
// import EmailSubscribe from '~/components/email-subscribe.vue'
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
// import RelativeTime from '~/components/relative-time.vue'
|
||||
import ListSummary from '~/components/list-summary.vue'
|
||||
|
||||
|
|
@ -183,6 +206,7 @@ export default {
|
|||
components: {
|
||||
// EmailSubscribe: () => process.client ? import('~/components/email-subscribe.vue') : null,
|
||||
ListSummary,
|
||||
LinkButton,
|
||||
RelativeTime: () => process.client ? import('~/components/relative-time.vue') : null
|
||||
},
|
||||
props: {
|
||||
|
|
@ -325,6 +349,11 @@ export default {
|
|||
methods: {
|
||||
getAppCategory,
|
||||
getAppEndpoint,
|
||||
getSearchLinks (app) {
|
||||
if (typeof app.searchLinks === 'undefined') return []
|
||||
|
||||
return app.searchLinks
|
||||
},
|
||||
// Search priorities
|
||||
titleStartsWith (query, app) {
|
||||
const matches = app.name.toLowerCase().startsWith(query)
|
||||
|
|
|
|||
133
components/video/bg-player.vue
Normal file
133
components/video/bg-player.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<div
|
||||
:style="{
|
||||
'left': '50%',
|
||||
'right': '50%',
|
||||
'margin-left': '-50vw',
|
||||
'margin-right': '-50vw',
|
||||
'mask-image': 'linear-gradient(to top, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0.25))',
|
||||
opacity: playing ? 1 : 0
|
||||
}"
|
||||
class="video-canvas w-screen flex justify-center bg-black transition-opacity duration-500 ease-in-out"
|
||||
>
|
||||
<div class="ratio-wrapper w-full">
|
||||
<div class="relative overflow-hidden w-full pb-16/9">
|
||||
<iframe
|
||||
ref="frame"
|
||||
:id="frameId"
|
||||
:src="`https://www.youtube.com/embed/${video.id}?enablejsapi=1&autoplay=1&modestbranding=1&playsinline=1&start=0&end=30&loop=1`"
|
||||
:style="{
|
||||
height: '200%',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)'
|
||||
}"
|
||||
class="absolute w-full object-cover"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
// components: {
|
||||
// VideoCard
|
||||
// },
|
||||
props: {
|
||||
video: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
player: null,
|
||||
playing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
frameId () {
|
||||
return `youtube-bg-${this._uid}`
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// Set frame ID here so that it's the same when Youtube API looks for it
|
||||
// this.frameId = `youtube-bg-${this._uid}`
|
||||
|
||||
const tag = document.createElement('script')
|
||||
tag.id = `youtube-bg-script-${this._uid}`
|
||||
tag.src = 'https://www.youtube.com/iframe_api'
|
||||
|
||||
const firstScriptTag = document.getElementsByTagName('script')[0]
|
||||
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag)
|
||||
|
||||
|
||||
window.onYouTubeIframeAPIReady = this.onYouTubeIframeAPIReady
|
||||
},
|
||||
methods: {
|
||||
restartVideo () {
|
||||
this.player.seekTo(0)
|
||||
this.player.playVideo()
|
||||
},
|
||||
|
||||
onPlayerEnd () {
|
||||
console.log('Video ended')
|
||||
|
||||
this.restartVideo()
|
||||
},
|
||||
onPlayerPlaying () {
|
||||
console.log('Player playing')
|
||||
|
||||
this.playing = true
|
||||
},
|
||||
onPlayerReady (event) {
|
||||
console.log('Player is ready', this.player)
|
||||
|
||||
// Mute the player
|
||||
this.player.mute()
|
||||
|
||||
this.player.playVideo()
|
||||
},
|
||||
onYouTubeIframeAPIReady () {
|
||||
// console.log('Youtube Embed API Ready')
|
||||
|
||||
const stateHandlers = {
|
||||
// unstarted
|
||||
'-1': () => null,
|
||||
// ended
|
||||
'0': this.onPlayerEnd,
|
||||
// playing
|
||||
'1': this.onPlayerPlaying,
|
||||
// paused
|
||||
'2': () => null,
|
||||
// buffering
|
||||
'3': () => null,
|
||||
// video cued
|
||||
'4': () => null,
|
||||
}
|
||||
|
||||
// console.log('YT', YT)
|
||||
|
||||
// console.log('frame', this.$refs['frame'])
|
||||
// console.log('frame id', this.$refs['frame'].id)
|
||||
|
||||
this.player = new YT.Player(this.$refs['frame'].id, {
|
||||
events: {
|
||||
'onReady': this.onPlayerReady,
|
||||
'onStateChange': event => {
|
||||
// console.log('state changed', event)
|
||||
|
||||
const stateHandler = stateHandlers[String(event.data)]
|
||||
// console.log('stateHandler', stateHandler)
|
||||
stateHandler(event)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
108
components/video/card.vue
Normal file
108
components/video/card.vue
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<template>
|
||||
|
||||
<div class="video-card">
|
||||
<a
|
||||
:href="video.endpoint"
|
||||
class=""
|
||||
>
|
||||
<div class="video-card-container relative overflow-hidden bg-black">
|
||||
<div class="video-card-image ratio-wrapper">
|
||||
<div class="relative overflow-hidden w-full pb-16/9">
|
||||
<picture>
|
||||
<source
|
||||
:sizes="thumbnailSizes"
|
||||
:data-srcset="thumbnailSrcset"
|
||||
type="image/jpg"
|
||||
>
|
||||
<img
|
||||
:data-src="video.thumbnails.default.url"
|
||||
:alt="video.name"
|
||||
class="lazyload absolute h-full w-full object-cover"
|
||||
>
|
||||
</picture>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
'--gradient-from-color': 'rgba(0, 0, 0, 1)',
|
||||
'--gradient-to-color': 'rgba(0, 0, 0, 0.7)'
|
||||
}"
|
||||
class="video-card-overlay absolute inset-0 flex justify-between items-start bg-gradient-to-tr from-black to-transparent p-4"
|
||||
>
|
||||
<div class="play-circle w-8 h-8 bg-white-2 flex justify-center items-center outline-0 rounded-full ease">
|
||||
<svg
|
||||
viewBox="0 0 18 18"
|
||||
style="width:18px;height:18px;margin-left:3px"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15.562 8.1L3.87.225c-.818-.562-1.87 0-1.87.9v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="pill"
|
||||
class="video-pill h-5 text-xs bg-white-2 flex justify-center items-center outline-0 rounded-full ease px-2"
|
||||
>
|
||||
{{ pill }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Video Text Content -->
|
||||
<div class="video-card-content absolute inset-0 flex items-end py-4 px-6">
|
||||
<div class="w-full text-sm text-left whitespace-normal">{{ video.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import 'lazysizes'
|
||||
|
||||
// import { getVideoEndpoint } from '~/helpers/app-derived.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
video: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
thumbnailSizes () {
|
||||
let maxWidth = 0
|
||||
Object.entries(this.video.thumbnails).forEach(([thumbnailKey, thumbnail]) => {
|
||||
if (thumbnail.width > maxWidth) maxWidth = thumbnail.width
|
||||
})
|
||||
|
||||
// example:
|
||||
// "(max-width: 640px) 100vw, 640px"
|
||||
return `(max-width: ${maxWidth}px) 100vw, ${maxWidth}px`
|
||||
},
|
||||
thumbnailSrcset () {
|
||||
// console.log('this.video', this.video)
|
||||
|
||||
// example:
|
||||
// https://vumbnail.com/358629078.jpg 640w, https://vumbnail.com/358629078_large.jpg 640w, https://vumbnail.com/358629078_medium.jpg 200w, https://vumbnail.com/358629078_small.jpg 100w
|
||||
return Object.entries(this.video.thumbnails).map(([thumbnailKey, thumbnail]) => {
|
||||
// console.log('thumbnail', thumbnail)
|
||||
return `${thumbnail.url} ${thumbnail.width}w`
|
||||
}).join(', ')
|
||||
},
|
||||
pill () {
|
||||
// if this video has a banchmark tag
|
||||
// then pill is 'Benchmark'
|
||||
if (this.video.tags.includes('benchmark')) return 'Benchmark'
|
||||
|
||||
// No pill
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
34
components/video/channel-credit.vue
Normal file
34
components/video/channel-credit.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="video.channel.id !== myChannelId"
|
||||
class="channel-credit"
|
||||
>
|
||||
<LinkButton
|
||||
:href="`https://www.youtube.com/channel/${video.channel.id}`"
|
||||
|
||||
target="_blank"
|
||||
>Subscribe to {{ video.channel.name }}</LinkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LinkButton
|
||||
},
|
||||
props: {
|
||||
video: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
myChannelId: 'UCB3jOb5QVjX7lYecvyCoTqQ'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
264
components/video/player.vue
Normal file
264
components/video/player.vue
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
<template>
|
||||
<div
|
||||
:style="{
|
||||
'left': '50%',
|
||||
'right': '50%',
|
||||
'margin-left': '-50vw',
|
||||
'margin-right': '-50vw'
|
||||
}"
|
||||
class="video-canvas w-screen flex flex-col justify-center items-center bg-black"
|
||||
>
|
||||
<div class="ratio-wrapper w-full max-w-4xl">
|
||||
<div class="relative overflow-hidden w-full pb-16/9">
|
||||
<iframe
|
||||
ref="frame"
|
||||
:id="frameId"
|
||||
:src="`https://www.youtube-nocookie.com/embed/${video.id}?enablejsapi=1&autoplay=1&modestbranding=1&playsinline=1`"
|
||||
class="absolute h-full w-full"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasTimestamps && hasPlayer"
|
||||
class="video-timestamps w-full max-w-4xl"
|
||||
>
|
||||
<div
|
||||
ref="timestamps-scroll-container"
|
||||
class="featured-apps overflow-x-auto overflow-y-visible whitespace-no-wrap py-2 space-x-2"
|
||||
>
|
||||
<button
|
||||
v-for="timestamp in timestamps"
|
||||
:key="timestamp.time"
|
||||
:ref="`timestamp-${timestamp.time}`"
|
||||
:data-time="timestamp.time"
|
||||
:class="[
|
||||
'inline-block text-xs rounded-lg py-1 px-2',
|
||||
'border-2 border-white focus:outline-none',
|
||||
(activeTimestamp && activeTimestamp.time === timestamp.time) ? 'border-opacity-100 bg-darkest' : 'border-opacity-0 neumorphic-shadow-inner'
|
||||
]"
|
||||
:class-groups="{
|
||||
shadow: 'neumorphic-shadow-inner'
|
||||
}"
|
||||
@click.stop="seekTo(timestamp.inSeconds); player.playVideo()"
|
||||
>{{ timestamp.fullText }}</button>
|
||||
</div>
|
||||
|
||||
<!-- activeTimestamp: {{ activeTimestamp }} -->
|
||||
<!-- playerTime: {{ playerTime }} -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LinkButton
|
||||
},
|
||||
props: {
|
||||
video: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
player: null,
|
||||
playing: false,
|
||||
progressInterval: null,
|
||||
playerTime: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
frameId () {
|
||||
return `youtube-player-${this.video.id}-${this._uid}`
|
||||
},
|
||||
timestamps () {
|
||||
return this.video.timestamps.map( timestamp => {
|
||||
const [ minutes, seconds ] = timestamp.time.split(':')
|
||||
|
||||
return {
|
||||
...timestamp,
|
||||
inSeconds: (minutes * 60) + Number(seconds)
|
||||
}
|
||||
})
|
||||
},
|
||||
hasTimestamps () {
|
||||
return this.timestamps.length > 0
|
||||
},
|
||||
hasPlayer () {
|
||||
return this.player !== null
|
||||
},
|
||||
activeTimestamp () {
|
||||
const currentTime = this.playerTime// / 100
|
||||
|
||||
const reversesTimestamps = [
|
||||
...this.timestamps
|
||||
]
|
||||
|
||||
// reversesTimestamps.reverse()
|
||||
|
||||
let foundTimestamp = null
|
||||
|
||||
for (const timestamp of reversesTimestamps) {
|
||||
const hasStarted = currentTime > 1
|
||||
const currentTimeisAfterPreviousTimestamp = (foundTimestamp !== null) ? currentTime > foundTimestamp.inSeconds : true
|
||||
// const isPastCurrentTime = currentTime > timestamp.inSeconds
|
||||
// const isBeforeCurrentTime = currentTime > timestamp.inSeconds
|
||||
const currentTimeIsBeforeThisTimestamp = currentTime < timestamp.inSeconds
|
||||
|
||||
if (currentTimeisAfterPreviousTimestamp && currentTimeIsBeforeThisTimestamp) {
|
||||
return foundTimestamp
|
||||
}
|
||||
|
||||
foundTimestamp = timestamp
|
||||
}
|
||||
|
||||
// No active timestamp
|
||||
return null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// whenever question changes, this function will run
|
||||
activeTimestamp: function (newTimestamp, oldTimestamp) {
|
||||
|
||||
// console.log('newTimestamp', newTimestamp)
|
||||
// console.log('oldTimestamp', oldTimestamp)
|
||||
|
||||
const hasOldAndNewTimestamps = newTimestamp !== null && oldTimestamp !== null
|
||||
|
||||
if (!hasOldAndNewTimestamps) return
|
||||
|
||||
if (newTimestamp.inSeconds !== oldTimestamp.inSeconds) {
|
||||
this.scrollRow ( newTimestamp )
|
||||
}
|
||||
},
|
||||
|
||||
video () {
|
||||
this.$nextTick(() => {
|
||||
this.initializePlayer()
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// Set frame ID here so that it's the same when Youtube API looks for it
|
||||
// this.frameId = `youtube-bg-${this._uid}`
|
||||
|
||||
this.initializePlayer()
|
||||
},
|
||||
methods: {
|
||||
scrollRow ( timestamp ) {
|
||||
|
||||
// If timestamp button doesn't exist
|
||||
// then stop
|
||||
if (!this.$refs[`timestamp-${timestamp.time}`]) return
|
||||
|
||||
const timestampsScroller = this.$refs['timestamps-scroll-container']
|
||||
const [ timestampButton ] = this.$refs[`timestamp-${timestamp.time}`]
|
||||
|
||||
// https://stackoverflow.com/a/63773123/1397641
|
||||
const newScrollPosition = timestampButton.offsetLeft - timestampsScroller.offsetLeft
|
||||
|
||||
timestampsScroller.scroll({ left: newScrollPosition, behavior: 'smooth' })
|
||||
},
|
||||
seekTo (timestampInSeconds) {
|
||||
this.player.seekTo(timestampInSeconds)
|
||||
},
|
||||
async initializePlayer () {
|
||||
// console.log('Youtube Embed API Ready')
|
||||
|
||||
// Clear player
|
||||
this.player = null
|
||||
|
||||
// Clear tprogession interval
|
||||
clearInterval(this.progressInterval)
|
||||
|
||||
// If there are no timestamps
|
||||
// then stop
|
||||
if (!this.hasTimestamps) return
|
||||
|
||||
if (typeof YT === 'undefined') {
|
||||
await this.initializeApi()
|
||||
}
|
||||
|
||||
const stateHandlers = {
|
||||
// unstarted
|
||||
'-1': () => null,
|
||||
// ended
|
||||
'0': () => null,
|
||||
// playing
|
||||
'1': this.onPlayerPlaying,
|
||||
// paused
|
||||
'2': this.onPlayerPaused,
|
||||
// buffering
|
||||
'3': () => null,
|
||||
// video cued
|
||||
'4': () => null,
|
||||
}
|
||||
|
||||
// console.log('frame', this.$refs['frame'])
|
||||
// console.log('frame id', this.$refs['frame'].id)
|
||||
|
||||
this.player = new YT.Player(this.$refs['frame'].id, {
|
||||
events: {
|
||||
'onReady': this.onPlayerReady,
|
||||
'onStateChange': event => {
|
||||
// console.log('state changed', event)
|
||||
|
||||
const stateHandler = stateHandlers[String(event.data)]
|
||||
// console.log('stateHandler', stateHandler)
|
||||
stateHandler(event)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
initializeApi () {
|
||||
return new Promise( resolve => {
|
||||
const tag = document.createElement('script')
|
||||
tag.id = `youtube-api-script-${this._uid}`
|
||||
tag.src = 'https://www.youtube.com/iframe_api'
|
||||
|
||||
const firstScriptTag = document.getElementsByTagName('script')[0]
|
||||
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag)
|
||||
|
||||
|
||||
window.onYouTubeIframeAPIReady = resolve
|
||||
})
|
||||
},
|
||||
|
||||
onPlayerPlaying () {
|
||||
console.log('Player playing')
|
||||
this.playing = true
|
||||
|
||||
this.progressInterval = setInterval(() => {
|
||||
// console.log('this.player.getCurrentTime()', this.player.getCurrentTime())
|
||||
|
||||
// If player is empty
|
||||
// then stop
|
||||
if (this.player === null) {
|
||||
clearInterval(this.progressInterval)
|
||||
return
|
||||
}
|
||||
|
||||
// console.log('this.player', this.player.hasOwnProperty('getCurrentTime'))
|
||||
|
||||
this.playerTime = this.player.getCurrentTime()
|
||||
}, 500)
|
||||
},
|
||||
onPlayerPaused () {
|
||||
console.log('Player paused')
|
||||
this.playing = false
|
||||
|
||||
clearInterval(this.progressInterval)
|
||||
},
|
||||
onPlayerReady (event) {
|
||||
console.log('Player is ready', this.player)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
109
components/video/row.vue
Normal file
109
components/video/row.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<div class="video-row relative w-full">
|
||||
<div
|
||||
ref="row"
|
||||
:style="{
|
||||
scrollSnapType: 'x mandatory'
|
||||
}"
|
||||
class="video-row-contents flex overflow-x-auto whitespace-no-wrap py-2 space-x-6"
|
||||
>
|
||||
<component
|
||||
v-for="video in videos"
|
||||
:is="cardType ( video )"
|
||||
:key="video.slug"
|
||||
:video="video"
|
||||
:style="{
|
||||
maxWidth: `${cardWidth}px`,
|
||||
flexBasis: `${cardWidth}px`,
|
||||
scrollSnapAlign: 'start'
|
||||
}"
|
||||
:class="[
|
||||
'w-full flex-shrink-0 flex-grow-0 border-2 border-transparent rounded-2xl overflow-hidden',
|
||||
(activeVideoId === video.id) ? 'border-white' : null
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:style="{
|
||||
top: '50%',
|
||||
}"
|
||||
class="absolute left-0 h-10 w-10 flex justify-center items-center transform -translate-y-1/2 -translate-x-1/2 bg-darker rounded-full"
|
||||
@click="scrollRow(cardWidth * -1)"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
style="transform: scaleX(-1);"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:style="{
|
||||
top: '50%',
|
||||
}"
|
||||
class="absolute right-0 h-10 w-10 flex justify-center items-center transform -translate-y-1/2 translate-x-1/2 bg-darker rounded-full"
|
||||
@click="scrollRow(cardWidth)"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import SubmitCard from '~/components/video/submit-card.vue'
|
||||
import VideoCard from '~/components/video/card.vue'
|
||||
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SubmitCard,
|
||||
VideoCard
|
||||
},
|
||||
props: {
|
||||
videos: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
activeVideoId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
cardWidth: {
|
||||
type: Number,
|
||||
default: 325
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
scrollRow ( pixels ) {
|
||||
this.$refs['row'].scrollBy({ left: pixels, behavior: 'smooth' })
|
||||
},
|
||||
isSubmitCard ( video ) {
|
||||
return video.endpoint.includes('https://docs.google.com/forms')
|
||||
},
|
||||
cardType ( video ) {
|
||||
if (this.isSubmitCard(video)) return SubmitCard
|
||||
|
||||
return VideoCard
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
69
components/video/submit-card.vue
Normal file
69
components/video/submit-card.vue
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
|
||||
<div class="video-card">
|
||||
<a
|
||||
:href="video.endpoint"
|
||||
class=""
|
||||
>
|
||||
<div class="video-card-container relative overflow-hidden bg-white">
|
||||
<div class="video-card-image ratio-wrapper">
|
||||
<div class="relative overflow-hidden w-full pb-16/9">
|
||||
<!-- <img
|
||||
:srcset="thumbnailSrcset"
|
||||
:sizes="thumbnailSizes"
|
||||
:src="video.thumbnails.default.url"
|
||||
:alt="video.name"
|
||||
class="absolute h-full w-full object-cover"
|
||||
> -->
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
'--gradient-from-color': 'rgba(0, 0, 0, 1)',
|
||||
'--gradient-to-color': 'rgba(0, 0, 0, 0.7)'
|
||||
}"
|
||||
class="video-card-overlay absolute inset-0 flex justify-between items-start bg-gradient-to-tr from-black to-transparent p-4"
|
||||
>
|
||||
<div class="plus-circle w-8 h-8 bg-white-2 flex justify-center items-center outline-0 rounded-full ease">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
style="width:18px;height:18px;"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 11v-11h1v11h11v1h-11v11h-1v-11h-11v-1h11z"
|
||||
/>
|
||||
<!-- Plus Icon: "M11 11v-11h1v11h11v1h-11v11h-1v-11h-11v-1h11z" -->
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Video Text Content -->
|
||||
<div class="video-card-content absolute inset-0 flex items-end py-4 px-6">
|
||||
<div class="w-full text-sm text-left whitespace-normal">Submit Video</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { getVideoEndpoint } from '~/helpers/app-derived.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
video: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
// computed: {},
|
||||
methods: {
|
||||
getVideoEndpoint
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -3,9 +3,20 @@
|
|||
export function getAppEndpoint ( app ) {
|
||||
// console.log('app', app)
|
||||
|
||||
if(app.category !== Object(app.category)) {
|
||||
console.warn('app has no categories', app)
|
||||
}
|
||||
|
||||
if (app.category.slug === 'homebrew') return `/formula/${app.slug}`
|
||||
|
||||
if (app.category.slug === 'games') return `/game/${app.slug}`
|
||||
|
||||
return `/app/${app.slug}`
|
||||
}
|
||||
|
||||
export function getVideoEndpoint ( video ) {
|
||||
|
||||
return `/tv/${video.slug}`
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
161
helpers/build-video-list.js
Normal file
161
helpers/build-video-list.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
|
||||
import slugify from 'slugify'
|
||||
import axios from 'axios'
|
||||
|
||||
import { byTimeThenNull } from './sort-list.js'
|
||||
import { getVideoEndpoint } from './app-derived.js'
|
||||
import parseGithubDate from './parse-github-date'
|
||||
|
||||
export function matchesWholeWord (needle, haystack) {
|
||||
return new RegExp('\\b' + needle + '\\b').test(haystack)
|
||||
}
|
||||
|
||||
const videoFeaturesApp = function (app, video) {
|
||||
const appFuzzyName = app.name.toLowerCase()
|
||||
if (video.title.toLowerCase().includes(appFuzzyName)) return true
|
||||
|
||||
const appIsInTimestamps = video.timestamps.map( timestamp => timestamp.fullText.toLowerCase()).includes(appFuzzyName)
|
||||
|
||||
if (appIsInTimestamps) return true
|
||||
|
||||
if (matchesWholeWord(appFuzzyName, video.description.toLowerCase())) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const generateVideoTags = function ( video ) {
|
||||
const tags = {
|
||||
'benchmark': {
|
||||
relatedWords: [
|
||||
'benchmarks',
|
||||
'comparison',
|
||||
'speed test',
|
||||
'bench mark',
|
||||
'bench marks'
|
||||
]
|
||||
},
|
||||
'performance': {
|
||||
relatedWords: [
|
||||
'speed'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const videoTags = new Set()
|
||||
|
||||
video.tags.forEach( tag => {
|
||||
videoTags.add(tag)
|
||||
})
|
||||
|
||||
// Match tags against video titles and descriptions
|
||||
for (const tagKey in tags) {
|
||||
|
||||
// Skip if this video already has this tag
|
||||
// then skip it
|
||||
if (videoTags.has(tagKey)) continue
|
||||
|
||||
const matchingWords = [
|
||||
tagKey,
|
||||
...tags[tagKey].relatedWords
|
||||
]
|
||||
|
||||
for (const tagWord of matchingWords) {
|
||||
|
||||
// Skip if this video already has this tag
|
||||
// then stop this loop
|
||||
if (videoTags.has(tagKey)) break
|
||||
|
||||
// Check title
|
||||
if (matchesWholeWord(tagWord.toLowerCase(), video.title.toLowerCase())) {
|
||||
videoTags.add(tagKey)
|
||||
|
||||
// console.log(video.title, 'has', tagKey, 'tag')
|
||||
|
||||
// We're done since the tag matched for the title
|
||||
continue
|
||||
}
|
||||
|
||||
// Check description
|
||||
if (matchesWholeWord(tagWord.toLowerCase(), video.description.toLowerCase())) {
|
||||
videoTags.add(tagKey)
|
||||
|
||||
// console.log(video.title, 'has', tagKey, 'tag')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return videoTags
|
||||
}
|
||||
|
||||
export default async function ( applist ) {
|
||||
|
||||
// Fetch Commits
|
||||
const response = await axios.get(process.env.VIDEO_SOURCE)
|
||||
// Extract commit from response data
|
||||
const fetchedVideos = response.data
|
||||
|
||||
// console.log('fetchedVideos', fetchedVideos)
|
||||
|
||||
const videos = []
|
||||
|
||||
for (const videoId in fetchedVideos) {
|
||||
|
||||
// Skip private videos
|
||||
if (fetchedVideos[videoId].title === 'Private video') continue
|
||||
|
||||
// Skip deleted videos
|
||||
if (fetchedVideos[videoId].title === 'Deleted video') continue
|
||||
|
||||
// Build video slug
|
||||
const slug = slugify(`${fetchedVideos[videoId].title}-i-${videoId}`, {
|
||||
lower: true,
|
||||
strict: true
|
||||
})
|
||||
|
||||
const apps = []
|
||||
// Generate new tag set based on api data
|
||||
const tags = generateVideoTags(fetchedVideos[videoId])
|
||||
|
||||
for ( const app of applist ) {
|
||||
if (videoFeaturesApp(app, fetchedVideos[videoId])) {
|
||||
apps.push(app.slug)
|
||||
|
||||
tags.add(app.category.slug)
|
||||
}
|
||||
}
|
||||
|
||||
// console.log('fetchedVideos[videoId].rawData.snippet', fetchedVideos[videoId].rawData.snippet)
|
||||
|
||||
const lastUpdated = {
|
||||
raw: fetchedVideos[videoId].rawData.snippet.publishedAt,
|
||||
timestamp: parseGithubDate(fetchedVideos[videoId].rawData.snippet.publishedAt).timestamp,
|
||||
}
|
||||
|
||||
// console.log('fetchedVideos[videoId].thumbnails', fetchedVideos[videoId].thumbnails)
|
||||
|
||||
videos.push({
|
||||
name: fetchedVideos[videoId].title,
|
||||
id: videoId,
|
||||
lastUpdated,
|
||||
apps,
|
||||
slug,
|
||||
channel:{
|
||||
name: fetchedVideos[videoId].rawData.snippet.channelTitle,
|
||||
id: fetchedVideos[videoId].rawData.snippet.channelId
|
||||
},
|
||||
// Convert tags set into array
|
||||
tags: Array.from(tags),
|
||||
timestamps: fetchedVideos[videoId].timestamps,
|
||||
thumbnails: fetchedVideos[videoId].rawData.snippet.thumbnails,
|
||||
endpoint: getVideoEndpoint({
|
||||
slug
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// console.log('videos', videos)
|
||||
|
||||
// publishedAt
|
||||
|
||||
return videos.sort(byTimeThenNull)
|
||||
}
|
||||
|
|
@ -3,6 +3,14 @@ import gameList from '~/static/game-list.json'
|
|||
import homebrewList from '~/static/homebrew-list.json'
|
||||
|
||||
import { byTimeThenNull } from '~/helpers/sort-list.js'
|
||||
import { videosRelatedToApp } from '~/helpers/related.js'
|
||||
import { getAppEndpoint } from '~/helpers/app-derived.js'
|
||||
|
||||
|
||||
export const allVideoAppsList = [
|
||||
...appList.sort(byTimeThenNull),
|
||||
...gameList,
|
||||
]
|
||||
|
||||
export const sortedAppList = appList.sort(byTimeThenNull)
|
||||
|
||||
|
|
@ -11,3 +19,54 @@ export const allList = [
|
|||
...homebrewList,
|
||||
...gameList,
|
||||
]
|
||||
|
||||
|
||||
export function makeAppSearchLinks (app) {
|
||||
|
||||
const videos = videosRelatedToApp(app)
|
||||
|
||||
// If there are no videos
|
||||
// then skip
|
||||
if (videos.length === 0) return []
|
||||
|
||||
const searchLinks = []
|
||||
|
||||
const appBenchmarksUrl = `${getAppEndpoint(app)}/benchmarks`
|
||||
|
||||
let hasPerformanceVideo = false
|
||||
|
||||
for (const video of videos) {
|
||||
// If there are no video tags
|
||||
// then skip
|
||||
if (video.tags.length === 0) continue
|
||||
|
||||
// If there's any benchmark video then add
|
||||
if (video.tags.includes('benchmark')) {
|
||||
// Add a benchmark link
|
||||
searchLinks.push({
|
||||
href: appBenchmarksUrl,
|
||||
label: 'Benchmarks'
|
||||
})
|
||||
|
||||
// then stop looking
|
||||
break
|
||||
}
|
||||
|
||||
if (video.tags.includes('performance')) {
|
||||
hasPerformanceVideo = true
|
||||
}
|
||||
}
|
||||
|
||||
// If there was no bechmark video found
|
||||
// but there was a performance video found
|
||||
// then push Performance link
|
||||
if (searchLinks.length === 0 && hasPerformanceVideo) {
|
||||
// Add a performance link
|
||||
searchLinks.push({
|
||||
href: appBenchmarksUrl,
|
||||
label: 'Performance'
|
||||
})
|
||||
}
|
||||
|
||||
return searchLinks
|
||||
}
|
||||
|
|
|
|||
57
helpers/related.js
Normal file
57
helpers/related.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { allVideoAppsList } from '~/helpers/get-list.js'
|
||||
import videoList from '~/static/video-list.json'
|
||||
|
||||
export function matchesWholeWord (needle, haystack) {
|
||||
return new RegExp('\\b' + needle + '\\b').test(haystack)
|
||||
}
|
||||
|
||||
export function appsRelatedToVideo ( video ) {
|
||||
const relatedApps = []
|
||||
|
||||
// Find the apps listed in this video
|
||||
for (const app of allVideoAppsList) {
|
||||
// Skip this app if it's not listed in the videos apps
|
||||
if (!video.apps.includes(app.slug)) continue
|
||||
|
||||
// Add this app to our featured app list
|
||||
relatedApps.push(app)
|
||||
}
|
||||
|
||||
return relatedApps
|
||||
}
|
||||
|
||||
export function videosRelatedToVideo ( video ) {
|
||||
const relatedVideos = {}
|
||||
|
||||
const featuredApps = appsRelatedToVideo( video )
|
||||
|
||||
// Find other videos that also feature this video's app
|
||||
for (const otherVideo of videoList) {
|
||||
for (const app of featuredApps) {
|
||||
// Skip if this app is not in the other video's apps
|
||||
if (!otherVideo.apps.includes(app.slug)) continue
|
||||
|
||||
// Skip if the other video is, in fact, this video
|
||||
if (otherVideo.slug === video.slug) continue
|
||||
|
||||
// Add this video to our related videos list
|
||||
relatedVideos[otherVideo.id] = otherVideo
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(relatedVideos)
|
||||
}
|
||||
|
||||
|
||||
export function videosRelatedToApp ( app ) {
|
||||
const relatedVideos = {}
|
||||
|
||||
// Find other videos that also feature this video's app
|
||||
for (const video of videoList) {
|
||||
if (!video.apps.includes(app.slug)) continue
|
||||
|
||||
relatedVideos[video.id] = video
|
||||
}
|
||||
|
||||
return Object.values(relatedVideos)
|
||||
}
|
||||
106
nuxt.config.js
106
nuxt.config.js
|
|
@ -5,9 +5,10 @@ import pkg from './package'
|
|||
import buildAppList from './helpers/build-app-list.js'
|
||||
import buildGamesList from './helpers/build-game-list.js'
|
||||
import buildHomebrewList from './helpers/build-homebrew-list.js'
|
||||
import buildVideoList from './helpers/build-video-list.js'
|
||||
|
||||
import { categories } from './helpers/categories.js'
|
||||
import { getAppEndpoint } from './helpers/app-derived.js'
|
||||
import { getAppEndpoint, getVideoEndpoint } from './helpers/app-derived.js'
|
||||
|
||||
|
||||
const listsOptions = [
|
||||
|
|
@ -25,40 +26,62 @@ const listsOptions = [
|
|||
}
|
||||
]
|
||||
|
||||
const videoListOptions = {
|
||||
buildMethod: buildVideoList,
|
||||
path: '/static/video-list.json',
|
||||
}
|
||||
|
||||
|
||||
const saveList = async function ( list, buildArgs = null ) {
|
||||
const methodName = `Building ${list.path}`
|
||||
console.time(methodName)
|
||||
|
||||
// Run the build method
|
||||
const builtList = await list.buildMethod(buildArgs)
|
||||
|
||||
// Make the relative path for our new JSON file
|
||||
const listFullPath = `.${list.path}`
|
||||
|
||||
// console.log('listFullPath', listFullPath)
|
||||
|
||||
// Write the list to JSON
|
||||
await fs.writeFile(listFullPath, JSON.stringify(builtList))
|
||||
|
||||
// Read back the JSON we just wrote to ensure it exists
|
||||
const savedListJSON = await fs.readFile(listFullPath, 'utf-8')
|
||||
|
||||
// console.log('savedListJSON', savedListJSON)
|
||||
|
||||
const savedList = JSON.parse(savedListJSON)
|
||||
|
||||
|
||||
console.timeEnd(methodName)
|
||||
|
||||
// Import the created JSON File
|
||||
return savedList
|
||||
}
|
||||
|
||||
|
||||
const storeAppLists = async function (builder) {
|
||||
|
||||
console.log('Build Lists started')
|
||||
|
||||
const savedLists = await Promise.all(listsOptions.map(async list => {
|
||||
const savedLists = await Promise.all(listsOptions.map(saveList))
|
||||
// Build and save list of videos based on app lists
|
||||
.then(async lists => {
|
||||
const [
|
||||
appList,
|
||||
gameList
|
||||
] = lists
|
||||
|
||||
const methodName = `Building ${list.path}`
|
||||
console.time(methodName)
|
||||
// Build a video app list with apps and games only
|
||||
const videoAppList = [
|
||||
...appList,
|
||||
...gameList
|
||||
].flat(1)
|
||||
|
||||
// Run the build method
|
||||
const builtList = await list.buildMethod()
|
||||
|
||||
// Make the relative path for our new JSON file
|
||||
const listFullPath = `.${list.path}`
|
||||
|
||||
// console.log('listFullPath', listFullPath)
|
||||
|
||||
// Write the list to JSON
|
||||
await fs.writeFile(listFullPath, JSON.stringify(builtList))
|
||||
|
||||
// Read back the JSON we just wrote to ensure it exists
|
||||
const savedListJSON = await fs.readFile(listFullPath, 'utf-8')
|
||||
|
||||
// console.log('savedListJSON', savedListJSON)
|
||||
|
||||
const savedList = JSON.parse(savedListJSON)
|
||||
|
||||
|
||||
console.timeEnd(methodName)
|
||||
|
||||
// Import the created JSON File
|
||||
return savedList
|
||||
}))
|
||||
return await saveList(videoListOptions, videoAppList)
|
||||
})
|
||||
|
||||
console.log('Build Lists finished')
|
||||
|
||||
|
|
@ -94,7 +117,12 @@ export default {
|
|||
]
|
||||
},
|
||||
routes() {
|
||||
return Promise.all(listsOptions.map(async list => {
|
||||
return Promise.all([
|
||||
...listsOptions,
|
||||
videoListOptions
|
||||
].map(async list => {
|
||||
// Read saved lists
|
||||
|
||||
const methodName = `Reading ${list.path}`
|
||||
console.time(methodName)
|
||||
|
||||
|
|
@ -117,13 +145,27 @@ export default {
|
|||
const [
|
||||
appRoutes,
|
||||
gameRoutes,
|
||||
videoRoutes,
|
||||
homebrewRoutes
|
||||
] = lists.map((list, listI) => {
|
||||
return list.map( app => {
|
||||
|
||||
const isVideo = (app.category === undefined)
|
||||
|
||||
if (isVideo) {
|
||||
return getVideoEndpoint(app)
|
||||
}
|
||||
|
||||
return getAppEndpoint(app)
|
||||
})
|
||||
})
|
||||
|
||||
// Build routes for app types that support benchmark endpoints
|
||||
const benchmarkRoutes = [
|
||||
...appRoutes,
|
||||
// ...gameRoutes,
|
||||
].flat(1).map( route => `${route}/benchmarks`)
|
||||
|
||||
// console.log('homebrewRoutes', homebrewRoutes)
|
||||
|
||||
const categoryRoutes = Object.keys(categories).map( slug => ({
|
||||
|
|
@ -135,7 +177,11 @@ export default {
|
|||
...appRoutes,
|
||||
...gameRoutes,
|
||||
...homebrewRoutes,
|
||||
...categoryRoutes
|
||||
|
||||
// Non-app routes
|
||||
...videoRoutes,
|
||||
...categoryRoutes,
|
||||
...benchmarkRoutes
|
||||
]
|
||||
})
|
||||
}
|
||||
|
|
|
|||
5
package-lock.json
generated
5
package-lock.json
generated
|
|
@ -7522,6 +7522,11 @@
|
|||
"launch-editor": "^2.2.1"
|
||||
}
|
||||
},
|
||||
"lazysizes": {
|
||||
"version": "5.3.0-beta1",
|
||||
"resolved": "https://registry.npmjs.org/lazysizes/-/lazysizes-5.3.0-beta1.tgz",
|
||||
"integrity": "sha512-4ZEoV4UXvz5L96XBuaFphGlq8BVi78kIA4kL3ls2qSYMEgGBdXMgsh8u+bTVjbdwbXfzXtWc71OH4EfEEPVZoA=="
|
||||
},
|
||||
"levn": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"@nuxtjs/sitemap": "^2.4.0",
|
||||
"axios": "^0.21.0",
|
||||
"cross-env": "^5.2.0",
|
||||
"lazysizes": "^5.3.0-beta1",
|
||||
"markdown-it": "^11.0.1",
|
||||
"marked": "^1.2.7",
|
||||
"node-html-parser": "^2.0.0",
|
||||
|
|
|
|||
265
pages/app/_slug/benchmarks.vue
Normal file
265
pages/app/_slug/benchmarks.vue
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
<template>
|
||||
<section class="container pb-16">
|
||||
<div class="flex flex-col items-center text-center space-y-8">
|
||||
<template
|
||||
v-if="video"
|
||||
>
|
||||
<VideoPlayer
|
||||
:video="video"
|
||||
class="pt-16"
|
||||
/>
|
||||
<ChannelCredit
|
||||
:video="video"
|
||||
class="flex w-full justify-start md:px-10"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
:style="{
|
||||
'left': '50%',
|
||||
'right': '50%',
|
||||
'margin-left': '-50vw',
|
||||
'margin-right': '-50vw'
|
||||
}"
|
||||
class="video-canvas w-screen flex justify-center bg-black pt-16"
|
||||
>
|
||||
<div class="ratio-wrapper w-full max-w-4xl">
|
||||
<div class="relative overflow-hidden w-full pb-16/9">
|
||||
<div class="absolute h-full w-full flex justify-center items-center">
|
||||
<div class="message text-4xl md:text-6xl font-hairline leading-tight text-center">No videos yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- <h1 class="title text-sm md:text-3xl font-semibold">
|
||||
{{ video.name }}
|
||||
</h1> -->
|
||||
|
||||
<div class="related-videos w-full max-w-4xl">
|
||||
<h2 class="subtitle text-xl md:text-2xl font-bold mb-3">
|
||||
Benchmark Videos
|
||||
</h2>
|
||||
<!-- <pre class="text-left">{{ benchmarkVideos }}</pre> -->
|
||||
<VideoRow
|
||||
:videos="benchmarkVideos"
|
||||
:active-video-id="activeVideoId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="performanceVideos.length !== 0"
|
||||
class="performance-videos w-full max-w-4xl"
|
||||
>
|
||||
<h2 class="subtitle text-xl md:text-2xl font-bold mb-3">
|
||||
Performance Videos
|
||||
</h2>
|
||||
<!-- <pre class="text-left">{{ performanceVideos }}</pre> -->
|
||||
<VideoRow
|
||||
:videos="performanceVideos"
|
||||
:active-video-id="activeVideoId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="moreVideos.length !== 0"
|
||||
class="related-videos w-full max-w-4xl"
|
||||
>
|
||||
<h2 class="subtitle text-xl md:text-2xl font-bold mb-3">
|
||||
More Videos
|
||||
</h2>
|
||||
<!-- <pre class="text-left">{{ relatedVideos }}</pre> -->
|
||||
<VideoRow
|
||||
:videos="moreVideos"
|
||||
:active-video-id="activeVideoId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- video: {{ video }} -->
|
||||
|
||||
<!-- <div class="links space-y-6 sm:space-x-6 mb-8">
|
||||
<LinkButton
|
||||
v-for="(link, i) in app.relatedLinks"
|
||||
:key="i"
|
||||
:href="link.href"
|
||||
target="_blank"
|
||||
class=""
|
||||
>{{ (i === 0) ? 'View' : link.label }}</LinkButton>
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
import EmailSubscribe from '~/components/email-subscribe.vue'
|
||||
import VideoRow from '~/components/video/row.vue'
|
||||
import VideoPlayer from '~/components/video/player.vue'
|
||||
import ChannelCredit from '~/components/video/channel-credit.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LinkButton,
|
||||
EmailSubscribe,
|
||||
VideoRow,
|
||||
VideoPlayer,
|
||||
ChannelCredit
|
||||
},
|
||||
async asyncData ({ params: { slug } }) {
|
||||
|
||||
const { allVideoAppsList } = await import('~/helpers/get-list.js')
|
||||
// const { default: videoList } = await import('~/static/video-list.json')
|
||||
|
||||
const { videosRelatedToApp } = await import('~/helpers/related.js')
|
||||
|
||||
const app = allVideoAppsList.find(app => (app.slug === slug))
|
||||
|
||||
const submitVideoCard = {
|
||||
endpoint: `https://docs.google.com/forms/d/e/1FAIpQLSeEVGM9vE7VcfLMy6fJkfU70X2VZ60rHDyhDQLtnAN4nso0WA/viewform?usp=pp_url&entry.1018125313=${app.name}`
|
||||
}
|
||||
|
||||
// const featuredApps = []
|
||||
|
||||
const relatedVideos = videosRelatedToApp( app ).map(video => {
|
||||
// console.log('video', video)
|
||||
return {
|
||||
...video,
|
||||
// endpoint: `#${video.id}`
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
app,
|
||||
allVideos: relatedVideos,
|
||||
submitVideoCard
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
activeVideoIndex: 0,
|
||||
benchmarkVideos: [],
|
||||
performanceVideos: [],
|
||||
moreVideos: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
video () {
|
||||
return this.allVideos[this.activeVideoIndex]
|
||||
},
|
||||
title () {
|
||||
return `${this.app.name} Benchmarks for Apple Silicon - Does It ARM`
|
||||
},
|
||||
description () {
|
||||
return `Apple Silicon benchmark, performance, and support videos for ${this.app.name}`
|
||||
},
|
||||
activeVideoId () {
|
||||
return (this.video === Object(this.video)) ? this.video.id : null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
||||
const nonBenchmarkVideos = []
|
||||
|
||||
// console.log('benchmarkVideos.length', this.benchmarkVideos.length)
|
||||
// console.log('performanceVideos.length', this.performanceVideos.length)
|
||||
// console.log('moreVideos.length', this.moreVideos.length)
|
||||
|
||||
// Move benchmark videos out of related videos
|
||||
this.allVideos.forEach((video, index) => {
|
||||
// console.log('video.name', video.name)
|
||||
// console.log('video.tags', video.tags)
|
||||
|
||||
if (!video.tags.includes('benchmark')) {
|
||||
nonBenchmarkVideos.push(video)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Add to benchmark videos
|
||||
this.benchmarkVideos.push(video)
|
||||
})
|
||||
|
||||
// console.log('Added benchmark videos')
|
||||
// console.log('benchmarkVideos.length', this.benchmarkVideos.length)
|
||||
// console.log('performanceVideos.length', this.performanceVideos.length)
|
||||
// console.log('moreVideos.length', this.moreVideos.length)
|
||||
|
||||
// Move performance videos out of related videos
|
||||
nonBenchmarkVideos.forEach((video, index) => {
|
||||
|
||||
if (!video.tags.includes('performance')) {
|
||||
this.moreVideos.push(video)
|
||||
return
|
||||
}
|
||||
|
||||
// Add to benchmark videos
|
||||
this.performanceVideos.push(video)
|
||||
})
|
||||
|
||||
// Append submit card to end
|
||||
this.benchmarkVideos.push(this.submitVideoCard)
|
||||
|
||||
|
||||
// console.log('Added performance videos')
|
||||
// console.log('benchmarkVideos.length', this.benchmarkVideos.length)
|
||||
// console.log('performanceVideos.length', this.performanceVideos.length)
|
||||
// console.log('moreVideos.length', this.moreVideos.length)
|
||||
},
|
||||
mounted () {
|
||||
window.onhashchange = this.loadVideoFromHash
|
||||
|
||||
if (location.hash.length !== 0) this.loadVideoFromHash()
|
||||
},
|
||||
methods: {
|
||||
loadVideoFromHash () {
|
||||
// console.log('location.hash', location.hash)
|
||||
|
||||
// Separate the video id from our window hash
|
||||
const hashId = location.hash.split('#')[1]
|
||||
|
||||
// Find the index of the video with the matching hash
|
||||
const newVideoIndex = this.allVideos.findIndex(video => {
|
||||
return video.id === hashId
|
||||
})
|
||||
|
||||
console.log('newVideoIndex', newVideoIndex)
|
||||
|
||||
// Load in the index to load out video
|
||||
this.activeVideoIndex = newVideoIndex
|
||||
|
||||
window.scroll({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.title,
|
||||
meta: [
|
||||
// hid is used as unique identifier. Do not use `vmid` for it as it will not work
|
||||
{
|
||||
'hid': 'description',
|
||||
'name': 'description',
|
||||
'content': this.description
|
||||
},
|
||||
|
||||
// Twitter Card
|
||||
{
|
||||
'hid': 'twitter:title',
|
||||
'property': 'twitter:title',
|
||||
'content': this.title
|
||||
},
|
||||
{
|
||||
'hid': 'twitter:description',
|
||||
'property': 'twitter:description',
|
||||
'content': this.description
|
||||
},
|
||||
{
|
||||
'property': 'twitter:url',
|
||||
'content': `${process.env.URL}${this.$nuxt.$route.path}`
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
<template>
|
||||
<section class="container py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h1 class="title text-sm md:text-2xl font-semibold">
|
||||
Does {{ app.name }} work on Apple Silicon?
|
||||
</h1>
|
||||
<h2 class="subtitle text-2xl md:text-5xl font-bold py-6">
|
||||
{{ app.text }}
|
||||
</h2>
|
||||
<div class="flex flex-col items-center text-center space-y-8">
|
||||
<div class="hero-heading space-y-6">
|
||||
<h1 class="title text-sm md:text-2xl font-semibold">
|
||||
Does {{ app.name }} work on Apple Silicon?
|
||||
</h1>
|
||||
<h2 class="subtitle text-2xl md:text-5xl font-bold">
|
||||
{{ app.text }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="subscribe space-y-6 sm:space-x-6 mb-4">
|
||||
<div class="subscribe">
|
||||
<AllUpdatesSubscribe
|
||||
:app-name="app.name"
|
||||
/>
|
||||
|
|
@ -24,6 +26,18 @@
|
|||
>{{ (i === 0) ? 'View' : link.label }}</LinkButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="relatedVideos.length !== 0"
|
||||
class="related-videos w-full"
|
||||
>
|
||||
<h2 class="subtitle text-xl md:text-2xl font-bold mb-3">
|
||||
Related Videos
|
||||
</h2>
|
||||
<VideoRow
|
||||
:videos="relatedVideos"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="report-links py-24 text-xs shadow-none">
|
||||
<div v-if="app.lastUpdated">
|
||||
<time
|
||||
|
|
@ -48,19 +62,43 @@
|
|||
import parseGithubDate from '~/helpers/parse-github-date'
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
import AllUpdatesSubscribe from '~/components/all-updates-subscribe.vue'
|
||||
import VideoRow from '~/components/video/row.vue'
|
||||
|
||||
import appList from '~/static/app-list.json'
|
||||
// import buildAppList from '~/helpers/build-app-list'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LinkButton,
|
||||
AllUpdatesSubscribe
|
||||
AllUpdatesSubscribe,
|
||||
VideoRow
|
||||
},
|
||||
async asyncData ({ params: { slug } }) {
|
||||
|
||||
const { default: videoList } = await import('~/static/video-list.json')
|
||||
const { videosRelatedToApp } = await import('~/helpers/related.js')
|
||||
|
||||
const app = appList.find(app => (app.slug === slug))
|
||||
|
||||
const relatedVideos = videosRelatedToApp(app)
|
||||
|
||||
// Find other videos that also feature this video's app
|
||||
// for (const video of videoList) {
|
||||
// if (!video.apps.includes(app.slug)) continue
|
||||
|
||||
// relatedVideos.push(video)
|
||||
// }
|
||||
|
||||
return {
|
||||
slug,
|
||||
app: appList.find(app => (app.slug === slug))
|
||||
app,
|
||||
relatedVideos: relatedVideos.map(video => {
|
||||
// console.log('video', video)
|
||||
return {
|
||||
...video,
|
||||
endpoint: `${slug}/benchmarks#${video.id}`
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
252
pages/benchmarks.vue
Normal file
252
pages/benchmarks.vue
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
<template>
|
||||
<section class="container relative md:static overflow-hidden md:overflow-visible pb-16">
|
||||
<div class="flex flex-col items-center text-center space-y-12">
|
||||
<BgPlayer
|
||||
:video="video"
|
||||
class="absolute overflow-hidden w-2x-screen md:w-full pointer-events-none"
|
||||
/>
|
||||
|
||||
<div class="page-heading flex justify-start w-full">
|
||||
<h1 class="title text-2xl leading-tight mt-12 mb-6">
|
||||
Benchmarks
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="line-separator border-white border-t-2 mb-12" />
|
||||
|
||||
<a
|
||||
:href="video.endpoint"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-col w-full justify-center items-center space-y-8 py-16 md:pt-0 md:pb-12 md:px-10"
|
||||
>
|
||||
<div
|
||||
class="play-circle w-16 h-16 bg-white-2 bg-blur flex justify-center items-center outline-0 rounded-full ease"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 18 18"
|
||||
style="width:24px;height:24px;margin-left:3px"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15.562 8.1L3.87.225c-.818-.562-1.87 0-1.87.9v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="title text-lg md:text-2xl font-semibold">
|
||||
{{ video.name }}
|
||||
</h2>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="features-apps w-full"
|
||||
>
|
||||
<hr class="w-full" >
|
||||
<div class="featured-apps overflow-x-auto overflow-y-visible whitespace-no-wrap py-2 space-x-2">
|
||||
<LinkButton
|
||||
v-for="app in featuredApps"
|
||||
:key="app.slug"
|
||||
:href="app.endpoint"
|
||||
:class="[
|
||||
'inline-block text-xs rounded-lg py-1 px-2',
|
||||
]"
|
||||
:class-groups="{
|
||||
shadow: 'neumorphic-shadow-inner'
|
||||
}"
|
||||
>{{ app.name }}</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
v-for="(row, key) in videoRows"
|
||||
:key="key"
|
||||
:class="`${key}-videos w-full max-w-4xl`"
|
||||
>
|
||||
<h2 class="subtitle text-xl md:text-2xl font-bold mb-3">
|
||||
{{ row.heading }}
|
||||
</h2>
|
||||
<!-- <pre class="text-left">{{ benchmarkVideos }}</pre> -->
|
||||
<VideoRow
|
||||
:videos="row.videos"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { getVideoEndpoint, getAppEndpoint } from '~/helpers/app-derived.js'
|
||||
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
import EmailSubscribe from '~/components/email-subscribe.vue'
|
||||
import VideoRow from '~/components/video/row.vue'
|
||||
import BgPlayer from '~/components/video/bg-player.vue'
|
||||
import ChannelCredit from '~/components/video/channel-credit.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LinkButton,
|
||||
EmailSubscribe,
|
||||
VideoRow,
|
||||
BgPlayer,
|
||||
ChannelCredit
|
||||
},
|
||||
async asyncData ({ params: { slug } }) {
|
||||
|
||||
const { appsRelatedToVideo } = await import('~/helpers/related.js')
|
||||
const { default: videoList } = await import('~/static/video-list.json')
|
||||
|
||||
// Get featured apps
|
||||
const featuredAppsSet = new Set()
|
||||
|
||||
videoList.slice(0, 24).forEach( video => {
|
||||
appsRelatedToVideo(video).forEach( app => {
|
||||
featuredAppsSet.add(app)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
video: videoList[0],
|
||||
featuredApps: Array.from(featuredAppsSet).map( app => {
|
||||
return {
|
||||
...app,
|
||||
endpoint: getAppEndpoint(app) + '/benchmarks'
|
||||
}
|
||||
}),
|
||||
allVideos: videoList.map( video => {
|
||||
return {
|
||||
...video,
|
||||
endpoint: getVideoEndpoint(video)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
videoRows: {
|
||||
'video-benchmarks': {
|
||||
heading: 'Video Editing Benchmarks',
|
||||
matchesCondition: video => {
|
||||
return video.tags.includes('benchmark') && video.tags.includes('video-and-motion-tools')
|
||||
},
|
||||
videos: []
|
||||
},
|
||||
'music-and-audio-tools': {
|
||||
heading: 'Music and DAW Performance',
|
||||
matchesCondition: video => {
|
||||
return video.tags.includes('music-and-audio-tools')
|
||||
},
|
||||
videos: []
|
||||
},
|
||||
// 'science-and-research-software': {
|
||||
// heading: 'Science and Research',
|
||||
// matchesCondition: video => {
|
||||
// return video.tags.includes('science-and-research-software')
|
||||
// },
|
||||
// videos: []
|
||||
// },
|
||||
'photo-and-graphic-tools': {
|
||||
heading: 'Photography and Design Compatibility',
|
||||
matchesCondition: video => {
|
||||
return video.tags.includes('photo-and-graphic-tools')
|
||||
},
|
||||
videos: []
|
||||
},
|
||||
'games': {
|
||||
heading: 'Gaming Benchmarks',
|
||||
matchesCondition: video => {
|
||||
return video.tags.includes('benchmark') && video.tags.includes('games')
|
||||
},
|
||||
videos: []
|
||||
},
|
||||
'benchmarks': {
|
||||
heading: 'Other Benchmark Videos',
|
||||
matchesCondition: video => video.tags.includes('benchmark'),
|
||||
videos: []
|
||||
},
|
||||
'performance': {
|
||||
heading: 'Performance Videos',
|
||||
matchesCondition: video => video.tags.includes('performance'),
|
||||
videos: []
|
||||
},
|
||||
|
||||
'other': {
|
||||
heading: 'More Videos',
|
||||
// Always true
|
||||
matchesCondition: () => true,
|
||||
videos: []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
title () {
|
||||
return `Benchmarks for Apple M1 and Apple Silicon - Does It ARM`
|
||||
},
|
||||
description () {
|
||||
// const featuredAppsString = this.featuredApps.slice(0, 5).map(app => app.name).join(', ')
|
||||
|
||||
return `Apple Silicon benchmark, performance, and compatibility videos`
|
||||
},
|
||||
activeVideoId () {
|
||||
return this.video.id
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
||||
// Move videos to relevant categories
|
||||
this.allVideos.forEach((video, index) => {
|
||||
// console.log('video.name', video.name)
|
||||
// console.log('video.tags', video.tags)
|
||||
|
||||
// Look through row conditions to see if video matches
|
||||
for (const row in this.videoRows) {
|
||||
if( this.videoRows[row].matchesCondition(video) ) {
|
||||
|
||||
// Add the matching video
|
||||
this.videoRows[row].videos.push(video)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
console.log('lengths', Object.values(this.videoRows).map(row => [row.heading, row.videos.length]))
|
||||
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.title,
|
||||
meta: [
|
||||
// hid is used as unique identifier. Do not use `vmid` for it as it will not work
|
||||
{
|
||||
'hid': 'description',
|
||||
'name': 'description',
|
||||
'content': this.description
|
||||
},
|
||||
|
||||
// Twitter Card
|
||||
{
|
||||
'hid': 'twitter:title',
|
||||
'property': 'twitter:title',
|
||||
'content': this.title
|
||||
},
|
||||
{
|
||||
'hid': 'twitter:description',
|
||||
'property': 'twitter:description',
|
||||
'content': this.description
|
||||
},
|
||||
{
|
||||
'property': 'twitter:url',
|
||||
'content': `${process.env.URL}${this.$nuxt.$route.path}`
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
220
pages/game/_slug/benchmarks.vue
Normal file
220
pages/game/_slug/benchmarks.vue
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<template>
|
||||
<section class="container pb-16">
|
||||
<div class="flex flex-col items-center text-center space-y-8">
|
||||
<template
|
||||
v-if="video"
|
||||
>
|
||||
<VideoPlayer
|
||||
:video="video"
|
||||
class="pt-16"
|
||||
/>
|
||||
<ChannelCredit
|
||||
:video="video"
|
||||
class="flex w-full justify-start md:px-10"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
:style="{
|
||||
'left': '50%',
|
||||
'right': '50%',
|
||||
'margin-left': '-50vw',
|
||||
'margin-right': '-50vw'
|
||||
}"
|
||||
class="video-canvas w-screen flex justify-center bg-black pt-16"
|
||||
>
|
||||
<div class="ratio-wrapper w-full max-w-4xl">
|
||||
<div class="relative overflow-hidden w-full pb-16/9">
|
||||
<div class="absolute h-full w-full flex justify-center items-center">
|
||||
<div class="message text-4xl md:text-6xl font-hairline leading-tight text-center">No videos yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-for="(row, key) in videoRows"
|
||||
:key="key"
|
||||
class="w-full max-w-4xl"
|
||||
>
|
||||
<div
|
||||
v-if="row.videos.length !== 0"
|
||||
:class="`${key}-videos w-full`"
|
||||
>
|
||||
<h2 class="subtitle text-xl md:text-2xl font-bold mb-3">
|
||||
{{ row.heading }}
|
||||
</h2>
|
||||
<!-- <pre class="text-left">{{ benchmarkVideos }}</pre> -->
|
||||
<VideoRow
|
||||
:videos="row.videos"
|
||||
:active-video-id="activeVideoId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
import EmailSubscribe from '~/components/email-subscribe.vue'
|
||||
import VideoRow from '~/components/video/row.vue'
|
||||
import VideoPlayer from '~/components/video/player.vue'
|
||||
import ChannelCredit from '~/components/video/channel-credit.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LinkButton,
|
||||
EmailSubscribe,
|
||||
VideoRow,
|
||||
VideoPlayer,
|
||||
ChannelCredit
|
||||
},
|
||||
async asyncData ({ params: { slug } }) {
|
||||
|
||||
const { allVideoAppsList } = await import('~/helpers/get-list.js')
|
||||
// const { default: videoList } = await import('~/static/video-list.json')
|
||||
|
||||
const { videosRelatedToApp } = await import('~/helpers/related.js')
|
||||
|
||||
const app = allVideoAppsList.find(app => (app.slug === slug))
|
||||
|
||||
// const submitVideoCard = {
|
||||
// endpoint: `https://docs.google.com/forms/d/e/1FAIpQLSeEVGM9vE7VcfLMy6fJkfU70X2VZ60rHDyhDQLtnAN4nso0WA/viewform?usp=pp_url&entry.1018125313=${app.name}`
|
||||
// }
|
||||
|
||||
// const featuredApps = []
|
||||
|
||||
const relatedVideos = videosRelatedToApp( app ).map(video => {
|
||||
// console.log('video', video)
|
||||
return {
|
||||
...video,
|
||||
// endpoint: `#${video.id}`
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
app,
|
||||
allVideos: relatedVideos,
|
||||
// submitVideoCard
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
activeVideoIndex: 0,
|
||||
videoRows: {
|
||||
'benchmarks': {
|
||||
heading: 'Benchmark Videos',
|
||||
matchesCondition: video => video.tags.includes('benchmark'),
|
||||
videos: []
|
||||
},
|
||||
'performance': {
|
||||
heading: 'Performance Videos',
|
||||
matchesCondition: video => video.tags.includes('performance'),
|
||||
videos: []
|
||||
},
|
||||
|
||||
'other': {
|
||||
heading: 'More Videos',
|
||||
// Always true
|
||||
matchesCondition: () => true,
|
||||
videos: []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
video () {
|
||||
return this.allVideos[this.activeVideoIndex]
|
||||
},
|
||||
title () {
|
||||
return `${this.app.name} Benchmarks for Apple Silicon - Does It ARM`
|
||||
},
|
||||
description () {
|
||||
return `Apple Silicon gaming benchmark, performance, and support videos for ${this.app.name}`
|
||||
},
|
||||
activeVideoId () {
|
||||
return (this.video === Object(this.video)) ? this.video.id : null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
||||
// Move videos to relevant categories
|
||||
this.allVideos.forEach((video, index) => {
|
||||
// console.log('video.name', video.name)
|
||||
// console.log('video.tags', video.tags)
|
||||
|
||||
// Look through row conditions to see if video matches
|
||||
for (const row in this.videoRows) {
|
||||
if( this.videoRows[row].matchesCondition(video) ) {
|
||||
|
||||
// Add the matching video
|
||||
this.videoRows[row].videos.push(video)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
console.log('lengths', Object.values(this.videoRows).map(row => [row.heading, row.videos.length]))
|
||||
|
||||
},
|
||||
mounted () {
|
||||
window.onhashchange = this.loadVideoFromHash
|
||||
|
||||
if (location.hash.length !== 0) this.loadVideoFromHash()
|
||||
},
|
||||
methods: {
|
||||
loadVideoFromHash () {
|
||||
// console.log('location.hash', location.hash)
|
||||
|
||||
// Separate the video id from our window hash
|
||||
const hashId = location.hash.split('#')[1]
|
||||
|
||||
// Find the index of the video with the matching hash
|
||||
const newVideoIndex = this.allVideos.findIndex(video => {
|
||||
return video.id === hashId
|
||||
})
|
||||
|
||||
console.log('newVideoIndex', newVideoIndex)
|
||||
|
||||
// Load in the index to load out video
|
||||
this.activeVideoIndex = newVideoIndex
|
||||
|
||||
window.scroll({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.title,
|
||||
meta: [
|
||||
// hid is used as unique identifier. Do not use `vmid` for it as it will not work
|
||||
{
|
||||
'hid': 'description',
|
||||
'name': 'description',
|
||||
'content': this.description
|
||||
},
|
||||
|
||||
// Twitter Card
|
||||
{
|
||||
'hid': 'twitter:title',
|
||||
'property': 'twitter:title',
|
||||
'content': this.title
|
||||
},
|
||||
{
|
||||
'hid': 'twitter:description',
|
||||
'property': 'twitter:description',
|
||||
'content': this.description
|
||||
},
|
||||
{
|
||||
'property': 'twitter:url',
|
||||
'content': `${process.env.URL}${this.$nuxt.$route.path}`
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -34,6 +34,27 @@ import ThomasCredit from '~/components/thomas-credit.vue'
|
|||
import gameList from '~/static/game-list.json'
|
||||
|
||||
export default {
|
||||
async asyncData () {
|
||||
const { sortedAppList, allList, allVideoAppsList, makeAppSearchLinks } = await import('~/helpers/get-list.js')
|
||||
const { default: gameList } = await import('~/static/game-list.json')
|
||||
|
||||
return {
|
||||
// Map game list
|
||||
gameList: gameList.map( app => {
|
||||
|
||||
return {
|
||||
name: app.name,
|
||||
status: app.status,
|
||||
slug: app.slug,
|
||||
// endpoint: app.endpoint,
|
||||
text: app.text,
|
||||
lastUpdated: app.lastUpdated,
|
||||
category: app.category,
|
||||
searchLinks: makeAppSearchLinks(app)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Search,
|
||||
LinkButton,
|
||||
|
|
@ -59,9 +80,9 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
gameList() {
|
||||
return gameList
|
||||
}
|
||||
// gameList() {
|
||||
// return gameList
|
||||
// }
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<Search
|
||||
:app-list="allList"
|
||||
:quick-buttons="quickButtons"
|
||||
:initial-limit="200"
|
||||
:initial-limit="100"
|
||||
@update:query="onQueryUpdate"
|
||||
>
|
||||
<template v-slot:before-search>
|
||||
|
|
@ -71,13 +71,33 @@ export default {
|
|||
// const { default: appList } = await import('~/static/app-list.json')
|
||||
// const { default: gamelist } = await import('~/static/game-list.json')
|
||||
|
||||
const { sortedAppList, allList } = await import('~/helpers/get-list.js')
|
||||
const { sortedAppList, allList, allVideoAppsList, makeAppSearchLinks } = await import('~/helpers/get-list.js')
|
||||
|
||||
const allAppSearchLinks = {}
|
||||
|
||||
// console.log('allVideoAppsList', allVideoAppsList)
|
||||
|
||||
allVideoAppsList.forEach( app => {
|
||||
// Make the search links
|
||||
const searchLinks = makeAppSearchLinks(app)
|
||||
|
||||
// If there are more than zero
|
||||
// add them to our list
|
||||
if (searchLinks.length > 0) {
|
||||
allAppSearchLinks[app.slug] = searchLinks
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// Filter app list to leave out data not needed for search
|
||||
initialAppList: sortedAppList.map( app => {
|
||||
|
||||
let searchLinks = []
|
||||
|
||||
if (typeof allAppSearchLinks[app.slug] !== 'undefined') {
|
||||
searchLinks = allAppSearchLinks[app.slug]
|
||||
}
|
||||
|
||||
return {
|
||||
name: app.name,
|
||||
status: app.status,
|
||||
|
|
@ -86,8 +106,10 @@ export default {
|
|||
text: app.text,
|
||||
lastUpdated: app.lastUpdated,
|
||||
category: app.category,
|
||||
searchLinks
|
||||
}
|
||||
}),
|
||||
allAppSearchLinks,
|
||||
customSummaryNumbers: getListSummaryNumbers(allList)
|
||||
}
|
||||
},
|
||||
|
|
@ -163,6 +185,8 @@ export default {
|
|||
// then stop
|
||||
if (this.fetchedAppList.length !== 0 || this.query.trim().length === 0) return
|
||||
|
||||
// console.log('this.allAppSearchLinks', this.allAppSearchLinks)
|
||||
|
||||
const fetchedListUrls = [
|
||||
'/game-list.json',
|
||||
'/homebrew-list.json'
|
||||
|
|
@ -179,7 +203,19 @@ export default {
|
|||
|
||||
// console.log('fetchedLists', fetchedLists)
|
||||
|
||||
this.fetchedAppList = fetchedLists.flat(1)
|
||||
this.fetchedAppList = fetchedLists.flat(1).map( app => {
|
||||
|
||||
let searchLinks = []
|
||||
|
||||
if (typeof this.allAppSearchLinks[app.slug] !== 'undefined') {
|
||||
searchLinks = this.allAppSearchLinks[app.slug]
|
||||
}
|
||||
|
||||
return {
|
||||
...app,
|
||||
searchLinks
|
||||
}
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,26 +55,31 @@
|
|||
import Search from '~/components/search.vue'
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
|
||||
import { byTimeThenNull } from '~/helpers/sort-list.js'
|
||||
|
||||
import { categories, getAppCategory } from '~/helpers/categories.js'
|
||||
|
||||
import appList from '~/static/app-list.json'
|
||||
import gamelist from '~/static/game-list.json'
|
||||
import homebrewList from '~/static/homebrew-list.json'
|
||||
|
||||
const allList = [
|
||||
...appList.sort(byTimeThenNull),
|
||||
...homebrewList,
|
||||
...gamelist,
|
||||
]
|
||||
|
||||
export default {
|
||||
async asyncData ({ params: { slug } }) {
|
||||
// Maybe I could import() here to reduce client script size
|
||||
const { sortedAppList, allList, allVideoAppsList, makeAppSearchLinks } = await import('~/helpers/get-list.js')
|
||||
const { default: gameList } = await import('~/static/game-list.json')
|
||||
|
||||
const filteredList = allList.filter(app => {
|
||||
return app.category.slug === slug
|
||||
})
|
||||
|
||||
return {
|
||||
slug,
|
||||
// app: appList.find(app => (app.slug === slug))
|
||||
categoryAppList: filteredList.map( app => {
|
||||
return {
|
||||
name: app.name,
|
||||
status: app.status,
|
||||
slug: app.slug,
|
||||
// endpoint: app.endpoint,
|
||||
text: app.text,
|
||||
lastUpdated: app.lastUpdated,
|
||||
category: app.category,
|
||||
searchLinks: makeAppSearchLinks(app)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
components: {
|
||||
|
|
@ -104,20 +109,6 @@ export default {
|
|||
category () {
|
||||
return categories[this.slug]
|
||||
},
|
||||
categoryAppList () {
|
||||
|
||||
const filteredList = allList.filter(app => {
|
||||
return app.category.slug === this.slug
|
||||
})
|
||||
|
||||
// const sortedList = list.sort(byTimeThenNull)
|
||||
|
||||
// if (this.category.slug === 'homebrew') {
|
||||
// return filteredList.slice(0, 300)
|
||||
// }
|
||||
|
||||
return filteredList
|
||||
},
|
||||
supportedAppList () {
|
||||
return this.categoryAppList.filter(app => {
|
||||
return app.status.includes('yes')
|
||||
|
|
|
|||
153
pages/tv/_slug.vue
Normal file
153
pages/tv/_slug.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<template>
|
||||
<section class="container pb-16">
|
||||
<div class="flex flex-col items-center text-center space-y-6">
|
||||
<VideoPlayer
|
||||
:video="video"
|
||||
class="pt-16"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="md:flex w-full justify-between space-y-4 md:space-y-0 md:px-10"
|
||||
>
|
||||
<h1 class="title text-lg md:text-2xl font-semibold">
|
||||
{{ video.name }}
|
||||
</h1>
|
||||
|
||||
<ChannelCredit
|
||||
:video="video"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr class="w-full" >
|
||||
|
||||
|
||||
<div
|
||||
v-if="featuredApps.length !== 0"
|
||||
class="related-apps w-full"
|
||||
>
|
||||
<h2 class="subtitle text-xl md:text-2xl font-bold mb-3">
|
||||
Related Apps
|
||||
</h2>
|
||||
<div class="featured-apps overflow-x-auto overflow-y-visible whitespace-no-wrap py-2 space-x-2">
|
||||
<LinkButton
|
||||
v-for="app in featuredApps"
|
||||
:key="app.slug"
|
||||
:href="getAppEndpoint(app)"
|
||||
:class="[
|
||||
'inline-block text-xs rounded-lg py-1 px-2',
|
||||
]"
|
||||
:class-groups="{
|
||||
shadow: 'neumorphic-shadow-inner'
|
||||
}"
|
||||
>{{ app.name }}</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="related-videos w-full">
|
||||
<h2 class="subtitle text-xl md:text-2xl font-bold mb-3">
|
||||
Related Videos
|
||||
</h2>
|
||||
<VideoRow
|
||||
:videos="relatedVideos"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- video: {{ video }} -->
|
||||
|
||||
<!-- <div class="links space-y-6 sm:space-x-6 mb-8">
|
||||
<LinkButton
|
||||
v-for="(link, i) in app.relatedLinks"
|
||||
:key="i"
|
||||
:href="link.href"
|
||||
target="_blank"
|
||||
class=""
|
||||
>{{ (i === 0) ? 'View' : link.label }}</LinkButton>
|
||||
</div> -->
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { getAppEndpoint } from '~/helpers/app-derived.js'
|
||||
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
import EmailSubscribe from '~/components/email-subscribe.vue'
|
||||
import VideoRow from '~/components/video/row.vue'
|
||||
import VideoPlayer from '~/components/video/player.vue'
|
||||
import ChannelCredit from '~/components/video/channel-credit.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LinkButton,
|
||||
EmailSubscribe,
|
||||
VideoRow,
|
||||
VideoPlayer,
|
||||
ChannelCredit
|
||||
},
|
||||
async asyncData ({ params: { slug } }) {
|
||||
|
||||
const { appsRelatedToVideo, videosRelatedToVideo } = await import('~/helpers/related.js')
|
||||
const { default: videoList } = await import('~/static/video-list.json')
|
||||
|
||||
// Find the video for our current page
|
||||
const video = videoList.find(video => (video.slug === slug))
|
||||
|
||||
// Get featured apps
|
||||
const featuredApps = appsRelatedToVideo(video)
|
||||
|
||||
// Get related videos
|
||||
const relatedVideos = videosRelatedToVideo(video)
|
||||
|
||||
return {
|
||||
video,
|
||||
featuredApps,
|
||||
// If no related video found just get the 12 newest ones
|
||||
relatedVideos: (relatedVideos.length !== 0) ? relatedVideos : videoList.slice(0, 12)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
title () {
|
||||
return `${this.video.name} - Does It ARM`
|
||||
},
|
||||
description () {
|
||||
const featuredAppsString = this.featuredApps.slice(0, 5).map(app => app.name).join(', ')
|
||||
|
||||
return `Apple Silicon performance and support videos for ${featuredAppsString}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAppEndpoint
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.title,
|
||||
meta: [
|
||||
// hid is used as unique identifier. Do not use `vmid` for it as it will not work
|
||||
{
|
||||
'hid': 'description',
|
||||
'name': 'description',
|
||||
'content': this.description
|
||||
},
|
||||
|
||||
// Twitter Card
|
||||
{
|
||||
'hid': 'twitter:title',
|
||||
'property': 'twitter:title',
|
||||
'content': this.title
|
||||
},
|
||||
{
|
||||
'hid': 'twitter:description',
|
||||
'property': 'twitter:description',
|
||||
'content': this.description
|
||||
},
|
||||
{
|
||||
'property': 'twitter:url',
|
||||
'content': `${process.env.URL}${this.$nuxt.$route.path}`
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -75,7 +75,13 @@ module.exports = {
|
|||
'1/2-screen': '50vh',
|
||||
'full-screen': '100vh'
|
||||
},
|
||||
width: {
|
||||
'1/2-screen': '50vw',
|
||||
'full-screen': '100vw',
|
||||
'2x-screen': '200vw'
|
||||
},
|
||||
minHeight: {
|
||||
'1/2-screen': '50vh',
|
||||
'3/4-screen': '75vh',
|
||||
},
|
||||
spacing: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue