Merge branch 'develop'

This commit is contained in:
Sam Carlton 2020-12-28 15:14:23 -06:00
commit f94ab19e18
28 changed files with 2231 additions and 110 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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); */

View file

@ -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)
}
}
}

View file

@ -146,6 +146,10 @@ export default {
label: 'Categories',
url: '/categories',
},
{
label: 'Benchmarks',
url: '/benchmarks',
},
{
label: 'Homebrew',
url: '/kind/homebrew',

View file

@ -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)

View 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
View 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>

View 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
View 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
View 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>

View 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>

View file

@ -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
View 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)
}

View file

@ -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
View 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)
}

View file

@ -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
View file

@ -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",

View file

@ -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",

View 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>

View file

@ -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
View 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>

View 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>

View file

@ -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 {

View file

@ -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
}

View file

@ -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
View 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>

View file

@ -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: {