mirror of
https://github.com/ThatGuySam/doesitarm.git
synced 2026-05-18 06:44:46 -07:00
Move nuxt components to components-nuxt
This commit is contained in:
parent
300d9598b4
commit
decaaabfe1
36 changed files with 62 additions and 61 deletions
|
|
@ -1,7 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
:style="{
|
||||
fontSize: '10rem'
|
||||
}"
|
||||
>🦾</div>
|
||||
</template>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# COMPONENTS
|
||||
|
||||
**This directory is not required, you can delete it if you don't want to use it.**
|
||||
|
||||
The components directory contains your Vue.js Components.
|
||||
|
||||
_Nuxt.js doesn't supercharge these components._
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
<template>
|
||||
|
||||
<div>
|
||||
<div
|
||||
v-if="feedbackMessage === null"
|
||||
>
|
||||
<form
|
||||
class="all-updates-subscribe text-xs relative"
|
||||
@submit.prevent="trySubmit"
|
||||
>
|
||||
<label
|
||||
v-if="isFocused"
|
||||
:for="inputId"
|
||||
class="block font-bold absolute"
|
||||
style="top: -2em;"
|
||||
>Email</label>
|
||||
<div class="mt-1 relative rounded-md shadow-sm">
|
||||
<div
|
||||
v-if="isFocused"
|
||||
class="absolute inset-y-0 left-0 pl-1 flex items-center pointer-events-none"
|
||||
>
|
||||
<!-- Heroicon name: mail -->
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="email"
|
||||
:class="inputClasslist"
|
||||
:placeholder="isFocused ? 'me@email.com' : placeholder"
|
||||
:aria-label="placeholder"
|
||||
name="all-updates-subscribe"
|
||||
style="width: 240px;"
|
||||
type="email"
|
||||
required
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="feedbackMessage"
|
||||
class="text-center p-4"
|
||||
>
|
||||
{{ feedbackMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
// appName: {
|
||||
// type: String,
|
||||
// required: true
|
||||
// },
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Send me regular app updates'
|
||||
},
|
||||
notes: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
inputClassGroups: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
email: '',
|
||||
|
||||
isFocused: false,
|
||||
feedbackMessage: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputId () {
|
||||
return `all-updates-subscribe-${this._uid}`
|
||||
},
|
||||
inputClasslist () {
|
||||
const defaultClassGroups = {
|
||||
general: 'form-input block w-full rounded-md py-1',
|
||||
shadow: 'neumorphic-shadow',
|
||||
bg: 'bg-darker',
|
||||
focus: 'pl-8',
|
||||
blur: 'placeholder-white text-center border border-transparent px-3',
|
||||
}
|
||||
|
||||
const mergedClassGroups = {
|
||||
...defaultClassGroups,
|
||||
...this.inputClassGroups
|
||||
}
|
||||
|
||||
if (this.isFocused) {
|
||||
delete mergedClassGroups.blur
|
||||
} else {
|
||||
delete mergedClassGroups.focus
|
||||
}
|
||||
|
||||
return Object.values(mergedClassGroups)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async trySubmit () {
|
||||
console.log('Trying submit')
|
||||
|
||||
// Set intermediate message
|
||||
this.feedbackMessage = 'Sending...'
|
||||
|
||||
// const pagePath = $nuxt.$route.path
|
||||
|
||||
// console.log('this.$config.allUpdateSubscribe', this.$config.allUpdateSubscribe)
|
||||
|
||||
// https://stackoverflow.com/questions/51995070/post-data-to-a-google-form-with-ajax/55496118#55496118
|
||||
const actionUrl = this.$config.allUpdateSubscribe
|
||||
|
||||
axios({
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
url: actionUrl,
|
||||
data: {
|
||||
// Email
|
||||
'email': this.email
|
||||
},
|
||||
}).then( response => {
|
||||
|
||||
this.feedbackMessage = 'We\'ll keep you informed!'
|
||||
|
||||
// console.log('response', response)
|
||||
// if (response.status === 200) {
|
||||
// this.feedbackMessage = '- We\'ll keep an eye on it for you!'
|
||||
// } else {
|
||||
// this.feedbackMessage = 'Oops! Something went wrong'
|
||||
// }
|
||||
}).catch( error => {
|
||||
console.warn('error', error)
|
||||
this.feedbackMessage = 'Something went wrong. Try refreshing. '
|
||||
})
|
||||
|
||||
// .catch(error => {
|
||||
// // handle error
|
||||
// console.error('Error Subscribing -', error)
|
||||
// this.feedbackMessage = 'Oops! Something went wrong'
|
||||
// })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
<template>
|
||||
|
||||
<div>
|
||||
<div
|
||||
v-if="feedbackMessage === null"
|
||||
>
|
||||
<form
|
||||
class="email-subscribe text-xs relative"
|
||||
@submit.prevent="trySubmit"
|
||||
>
|
||||
<label
|
||||
v-if="isFocused"
|
||||
:for="inputId"
|
||||
class="block font-bold absolute"
|
||||
style="top: -2em;"
|
||||
>Email</label>
|
||||
<div class="mt-1 relative rounded-md shadow-sm">
|
||||
<div
|
||||
v-if="isFocused"
|
||||
class="absolute inset-y-0 left-0 pl-1 flex items-center pointer-events-none"
|
||||
>
|
||||
<!-- Heroicon name: mail -->
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-model="email"
|
||||
:class="inputClasslist"
|
||||
:placeholder="isFocused ? 'me@email.com' : 'Tell me when this changes'"
|
||||
aria-label="Email Address"
|
||||
name="email-subscribe"
|
||||
style="width: 230px;"
|
||||
type="email"
|
||||
required
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="feedbackMessage"
|
||||
class="text-center p-4"
|
||||
>
|
||||
{{ feedbackMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
appName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
notes: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
inputClassGroups: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
email: '',
|
||||
|
||||
isFocused: false,
|
||||
feedbackMessage: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputId () {
|
||||
return `email-subscribe-${this._uid}`
|
||||
},
|
||||
inputClasslist () {
|
||||
const defaultClassGroups = {
|
||||
general: 'form-input block w-full rounded-md py-1',
|
||||
shadow: 'neumorphic-shadow',
|
||||
bg: 'bg-darker',
|
||||
focus: 'pl-8',
|
||||
blur: 'placeholder-white text-center border border-transparent px-3',
|
||||
}
|
||||
|
||||
const mergedClassGroups = {
|
||||
...defaultClassGroups,
|
||||
...this.inputClassGroups
|
||||
}
|
||||
|
||||
if (this.isFocused) {
|
||||
delete mergedClassGroups.blur
|
||||
} else {
|
||||
delete mergedClassGroups.focus
|
||||
}
|
||||
|
||||
return Object.values(mergedClassGroups)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async trySubmit () {
|
||||
console.log('Trying submit')
|
||||
|
||||
// Set intermediate message
|
||||
this.feedbackMessage = 'Sending...'
|
||||
|
||||
const formActionUrl = `https://docs.google.com/forms/d/e/1FAIpQLSdWUAVabT3i1ExfnPgRKnk-s-aWLlOuy0d5JjMKDwKtrwXj1Q/formResponse?entry.710297191=${this.appName}&emailAddress=${this.email}&submit=Submit`
|
||||
|
||||
|
||||
axios({
|
||||
method: 'get',
|
||||
url: formActionUrl,
|
||||
// data: {
|
||||
// // Email
|
||||
// 'emailAddress': this.email,
|
||||
|
||||
// // App Name
|
||||
// 'entry.710297191': this.appName,
|
||||
// // Notes
|
||||
// // 'entry.2040856090': '',
|
||||
|
||||
// 'submit': 'Submit'
|
||||
// },
|
||||
}).finally( response => {
|
||||
|
||||
this.feedbackMessage = 'We\'ll keep an eye on it for you!'
|
||||
|
||||
// console.log('response', response)
|
||||
// if (response.status === 200) {
|
||||
// this.feedbackMessage = '- We\'ll keep an eye on it for you!'
|
||||
// } else {
|
||||
// this.feedbackMessage = 'Oops! Something went wrong'
|
||||
// }
|
||||
})
|
||||
|
||||
// .catch(error => {
|
||||
// // handle error
|
||||
// console.error('Error Subscribing -', error)
|
||||
// this.feedbackMessage = 'Oops! Something went wrong'
|
||||
// })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'vue-full-screen-file-drop',
|
||||
'fixed inset-0',
|
||||
classes
|
||||
]"
|
||||
:style="{
|
||||
'z-index': 10000
|
||||
}"
|
||||
>
|
||||
<slot>
|
||||
<div
|
||||
class="vue-full-screen-file-drop__content"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
</slot>
|
||||
<input
|
||||
ref="file-selector"
|
||||
:style="{
|
||||
//'z-index': 10001
|
||||
}"
|
||||
type="file"
|
||||
accept="application/**"
|
||||
multiple
|
||||
class="absolute inset-0 w-screen h-screen"
|
||||
@change="fileInputChanged"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
// name: 'VueFullScreenFileDrop',
|
||||
props: {
|
||||
formFieldName: {
|
||||
type: String,
|
||||
default: 'upload',
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: 'Upload Files',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
lastTarget: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
classes() {
|
||||
return {
|
||||
'vue-full-screen-file-drop--visible': true //this.visible,
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
window.addEventListener('dragenter', this.onDragEnter);
|
||||
window.addEventListener('dragleave', this.onDragLeave);
|
||||
window.addEventListener('dragover', this.onDragOver);
|
||||
// window.addEventListener('drop', this.onDrop);
|
||||
},
|
||||
beforeDestroy () {
|
||||
window.removeEventListener('dragenter', this.onDragEnter);
|
||||
window.removeEventListener('dragleave', this.onDragLeave);
|
||||
window.removeEventListener('dragover', this.onDragOver);
|
||||
// window.removeEventListener('drop', this.onDrop);
|
||||
},
|
||||
methods: {
|
||||
onDragEnter(e) {
|
||||
this.lastTarget = e.target;
|
||||
this.visible = true;
|
||||
},
|
||||
onDragLeave(e) {
|
||||
if (e.target === this.lastTarget) {
|
||||
this.visible = false;
|
||||
}
|
||||
},
|
||||
onDragOver(e) {
|
||||
e.preventDefault();
|
||||
},
|
||||
onDrop(e) {
|
||||
e.preventDefault();
|
||||
// this.visible = false;
|
||||
// const files = e.dataTransfer.files;
|
||||
// const formData = this.getFormData(files);
|
||||
// this.$emit('drop', formData, files);
|
||||
},
|
||||
async fileInputChanged () {
|
||||
console.log('file-selector', this.$refs['file-selector'])
|
||||
const files = this.$refs['file-selector'].files
|
||||
|
||||
console.log('fileInputChanged files', files)
|
||||
|
||||
this.visible = false;
|
||||
// const files = e.dataTransfer.files;
|
||||
const formData = this.getFormData(files);
|
||||
this.$emit('drop', formData, files);
|
||||
},
|
||||
getFormData(files) {
|
||||
const formData = new FormData();
|
||||
Array.prototype.forEach.call(files, file => {
|
||||
formData.append(this.formFieldName, file, file.name);
|
||||
});
|
||||
return formData;
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='css'>
|
||||
.vue-full-screen-file-drop {
|
||||
/* position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
width: 100%;
|
||||
height: 100%; */
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: visibility 200ms, opacity 200ms;
|
||||
}
|
||||
.vue-full-screen-file-drop--visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.vue-full-screen-file-drop__content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
font-size: 4em;
|
||||
}
|
||||
.vue-full-screen-file-drop__content:before {
|
||||
border: 5px dashed #fff;
|
||||
content: "";
|
||||
bottom: 60px;
|
||||
left: 60px;
|
||||
position: absolute;
|
||||
right: 60px;
|
||||
top: 60px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
<template>
|
||||
|
||||
<a
|
||||
:href="href"
|
||||
:target="target"
|
||||
:rel="rel"
|
||||
:class="classlist"
|
||||
role="button"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: {
|
||||
href: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
target: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
classGroups: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
rel () {
|
||||
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-bold',
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
<template>
|
||||
|
||||
<div class="list-summary space-y-2">
|
||||
|
||||
<div>
|
||||
<strong>{{ total }} listed</strong>,
|
||||
<span>
|
||||
<span
|
||||
v-for="percentage in percentages"
|
||||
:key="percentage.emoji"
|
||||
:class="[
|
||||
percentage.textColor
|
||||
]"
|
||||
>{{ percentage.emoji }} <strong>{{ percentage.percent }}%</strong> {{ percentage.verbiage }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-2 flex flex-1">
|
||||
<div
|
||||
v-for="(percentage, index) in nonEmptyPercentages"
|
||||
:key="percentage.emoji"
|
||||
:style="`width: ${percentage.percent}%;`"
|
||||
:class="[
|
||||
percentage.bgColor,
|
||||
(index === 0) ? 'rounded-l-full' : null,
|
||||
(index === nonEmptyPercentages.length - 1) ? 'rounded-r-full' : null
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import getListSummaryNumbers from '~/helpers/get-list-summary-numbers.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
appList: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
customNumbers: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {
|
||||
total: null,
|
||||
nativePercent: null,
|
||||
rosettaPercent: null,
|
||||
unreportedPercent: null,
|
||||
unsupportedPercent: null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
nativePercent: null,
|
||||
rosettaPercent: null,
|
||||
unreportedPercent: null,
|
||||
unsupportedPercent: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
total () {
|
||||
return (this.customNumbers.total) ? this.customNumbers.total : this.appList.length
|
||||
},
|
||||
percentages () {
|
||||
return [
|
||||
{
|
||||
textColor: 'text-green-500',
|
||||
bgColor: 'bg-green-500',
|
||||
emoji: '✅',
|
||||
percent: this.nativePercent,
|
||||
verbiage: `Native, `
|
||||
},
|
||||
{
|
||||
textColor: 'text-green-200',
|
||||
bgColor: 'bg-green-200',
|
||||
emoji: '✳️',
|
||||
percent: this.rosettaPercent,
|
||||
verbiage: `Rosetta, `
|
||||
},
|
||||
{
|
||||
textColor: 'text-orange-500',
|
||||
bgColor: 'bg-orange-500',
|
||||
emoji: '🔶',
|
||||
percent: this.unreportedPercent,
|
||||
verbiage: `need info, `
|
||||
},
|
||||
{
|
||||
textColor: 'text-red',
|
||||
bgColor: 'bg-red',
|
||||
emoji: '🚫',
|
||||
percent: this.unsupportedPercent,
|
||||
verbiage: `unsupported. `
|
||||
},
|
||||
].filter( percentage => {
|
||||
const isZero = (percentage.percent === 0)
|
||||
const isUnreported = (percentage.emoji === '🔶')
|
||||
|
||||
// Filter out
|
||||
if (isUnreported && isZero) return false
|
||||
|
||||
return true
|
||||
})
|
||||
},
|
||||
nonEmptyPercentages () {
|
||||
return this.percentages.filter(percentage => {
|
||||
return Number(percentage.percent) !== 0
|
||||
})
|
||||
}
|
||||
},
|
||||
created () {
|
||||
// console.log('total apps ', this.total)
|
||||
|
||||
const hasCustomNumbers = Object.entries(this.customNumbers).some(([key, number]) => number !== null)
|
||||
|
||||
if (hasCustomNumbers) {
|
||||
|
||||
this.nativePercent = this.customNumbers.nativePercent
|
||||
this.rosettaPercent = this.customNumbers.rosettaPercent
|
||||
this.unreportedPercent = this.customNumbers.unreportedPercent
|
||||
this.unsupportedPercent = this.customNumbers.unsupportedPercent
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const summaryNumbers = getListSummaryNumbers(this.appList)
|
||||
|
||||
this.nativePercent = summaryNumbers.nativePercent
|
||||
this.rosettaPercent = summaryNumbers.rosettaPercent
|
||||
this.unreportedPercent = summaryNumbers.unreportedPercent
|
||||
this.unsupportedPercent = summaryNumbers.unsupportedPercent
|
||||
|
||||
// console.log('this.nativePercent', this.nativePercent)
|
||||
// console.log('this.unsupportedPercent', this.unsupportedPercent)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
<template>
|
||||
|
||||
<nav
|
||||
:class="[
|
||||
'fixed top-0 left-0 right-0 flex z-navbar',
|
||||
'bg-gradient-to-bl from-dark to-darker bg-fixed'
|
||||
]"
|
||||
>
|
||||
<div class="mobile-menu-container flex items-center lg:hidden p-2">
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<a
|
||||
:class="[
|
||||
'mobile-menu-toggle rounded-md p-2',
|
||||
'inline-flex items-center justify-center',
|
||||
'text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:bg-gray-700 focus:text-white',
|
||||
'transition duration-150 ease-in-out'
|
||||
]"
|
||||
href="#mobile-menu"
|
||||
aria-label="Main menu"
|
||||
>
|
||||
<!-- Icon when menu is closed. -->
|
||||
<svg
|
||||
class="parent-focus:hidden h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<!-- Icon when menu is open. -->
|
||||
<svg
|
||||
class="hidden parent-focus:visible h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
id="mobile-menu"
|
||||
:class="[
|
||||
'mobile-menu hidden target:visible lg:hidden absolute bg-blur top-0 left-0 right-0 w-full py-3 px-2',
|
||||
]"
|
||||
>
|
||||
<!-- Mobile menu button -->
|
||||
<a
|
||||
:class="[
|
||||
'mobile-menu-close rounded-md p-2',
|
||||
'inline-flex items-center justify-center',
|
||||
'text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:bg-gray-700 focus:text-white',
|
||||
'transition duration-150 ease-in-out'
|
||||
]"
|
||||
href="#"
|
||||
aria-label="Main menu"
|
||||
>
|
||||
<!-- Icon when menu is open. -->
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<div class="px-2 pt-2 pb-3 lg:px-3">
|
||||
<a
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
:href="item.url"
|
||||
:class="[
|
||||
'mt-1 block px-3 py-2 rounded-md text-base font-medium text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700 transition duration-150 ease-in-out',
|
||||
($nuxt.$route.path === item.url) ? 'text-white bg-gray-900 hover:text-white' : 'text-gray-300 hover:bg-gray-700'
|
||||
]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
|
||||
<div class="reponsive-menu-container relative w-full max-w-7xl mx-auto lg:px-6">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center text-4xl lg:text-5xl py-3">
|
||||
<div>🦾</div>
|
||||
</div>
|
||||
<div class="hidden lg:ml-6 lg:flex lg:items-center space-x-4">
|
||||
<a
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
:href="item.url"
|
||||
:class="[
|
||||
'px-3 py-2 rounded-md text-sm font-medium leading-5 focus:outline-none focus:text-white focus:bg-gray-700 transition duration-150 ease-in-out',
|
||||
($nuxt.$route.path === item.url) ? 'text-white bg-darker hover:text-white neumorphic-shadow' : 'text-gray-300 hover:bg-darker hover:neumorphic-shadow'
|
||||
]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<!-- <span class="rounded-md shadow-sm">
|
||||
<LinkButton
|
||||
href="https://prf.hn/l/7JG0bEj"
|
||||
class="relative inline-flex items-center border-indigo-500"
|
||||
>
|
||||
<svg
|
||||
class="-ml-1 mr-2 h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Parallels is now Apple Silicon native!</span>
|
||||
</LinkButton>
|
||||
|
||||
</span> -->
|
||||
|
||||
<a
|
||||
:class="[
|
||||
'underline px-3 py-2 rounded-md text-xs font-medium leading-5 focus:outline-none focus:text-white focus:bg-gray-700 transition duration-150 ease-in-out',
|
||||
//($nuxt.$route.path === item.url) ? 'text-white bg-darker hover:text-white neumorphic-shadow' : 'text-gray-300 hover:bg-darker hover:neumorphic-shadow'
|
||||
]"
|
||||
href="https://prf.hn/l/7JG0bEj"
|
||||
>
|
||||
🅿️ Windows on ARM now works on Parallels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
Mobile menu, toggle classes based on menu state.
|
||||
|
||||
Menu open: "block", Menu closed: "hidden"
|
||||
-->
|
||||
</nav>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LinkButton
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => ([
|
||||
{
|
||||
label: 'Home',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
url: '/categories',
|
||||
},
|
||||
{
|
||||
label: 'Devices',
|
||||
url: '/devices',
|
||||
},
|
||||
{
|
||||
label: 'Benchmarks',
|
||||
url: '/benchmarks',
|
||||
},
|
||||
{
|
||||
label: 'Homebrew',
|
||||
url: '/kind/homebrew',
|
||||
},
|
||||
{
|
||||
label: 'Games',
|
||||
url: '/games',
|
||||
},
|
||||
{
|
||||
label: 'Apple Silicon App Test',
|
||||
url: '/apple-silicon-app-test',
|
||||
},
|
||||
])
|
||||
}
|
||||
},
|
||||
// data: function () {
|
||||
// return {
|
||||
// // isOpen: false
|
||||
// }
|
||||
// }
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<template>
|
||||
|
||||
<span>{{ relativeTime }}</span>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import parseDate from '~/helpers/parse-date'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
timestamp: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
relativeTime () {
|
||||
return parseDate(this.timestamp).relative
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,610 +0,0 @@
|
|||
<template>
|
||||
<div class="search w-full">
|
||||
|
||||
<slot name="before-search">
|
||||
<div class="list-summary-wrapper flex justify-center text-center text-sm my-4">
|
||||
|
||||
<ListSummary
|
||||
:app-list="appList"
|
||||
class="max-w-4xl"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div class="search-input relative">
|
||||
<input
|
||||
id="search"
|
||||
ref="search"
|
||||
:autofocus="autofocus"
|
||||
v-model="query"
|
||||
aria-label="Type here to Search"
|
||||
class="appearance-none w-full text-white font-hairline sm:text-5xl outline-none bg-transparent p-3"
|
||||
type="search"
|
||||
placeholder="Type to Search"
|
||||
autocomplete="off"
|
||||
@keyup="queryResults(query); scrollInputToTop()"
|
||||
>
|
||||
<div class="search-input-separator border-white border-t-2" />
|
||||
<div class="quick-buttons overflow-x-auto whitespace-no-wrap py-2 space-x-2">
|
||||
<button
|
||||
v-for="button in quickButtons"
|
||||
:key="button.query"
|
||||
:class="[
|
||||
'inline-block text-xs rounded-lg py-1 px-2',
|
||||
'border-2 border-white focus:outline-none',
|
||||
query.includes(button.query) ? 'border-opacity-50 bg-darkest' : 'border-opacity-0 neumorphic-shadow-inner'
|
||||
]"
|
||||
@click="toggleFilter(button.query); queryResults(query)"
|
||||
>{{ button.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="search-container"
|
||||
class="search-container relative divide-y divide-gray-700 w-full rounded-lg border border-gray-700 bg-gradient-to-br from-darker to-dark my-8 px-5"
|
||||
>
|
||||
|
||||
<svg style="display: none;">
|
||||
<defs>
|
||||
<path
|
||||
id="chevron-right"
|
||||
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"
|
||||
/>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<!-- hasStartedAnyQuery: {{ hasStartedAnyQuery }} -->
|
||||
|
||||
<div
|
||||
v-if="chunkedResults.length === 0"
|
||||
class="text-center py-4"
|
||||
>
|
||||
No apps found
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-for="(results, i) in chunkedResults"
|
||||
:key="`results-chunk-${i}`"
|
||||
class="results-container divide-y divide-gray-700"
|
||||
>
|
||||
<li
|
||||
v-for="(app, i) in results"
|
||||
:key="`${app.slug}-${i}`"
|
||||
:ref="`${app.slug}-row`"
|
||||
:data-app-slug="app.slug"
|
||||
class="relative"
|
||||
>
|
||||
<!-- 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-5"
|
||||
style="transition-property: border;"
|
||||
>
|
||||
<template v-if="seenItems[app.slug] === false && hasStartedAnyQuery === false">
|
||||
{{ getAppCategory(app).icon ? `${getAppCategory(app).icon} ${app.name}` : app.name }}
|
||||
<div class="text-sm leading-5 font-bold">
|
||||
{{ app.text }}
|
||||
</div>
|
||||
<!-- app.endpoint: {{ app.endpoint }} -->
|
||||
</template>
|
||||
<template v-else>
|
||||
<client-only>
|
||||
<div class="absolute hidden left-0 h-12 w-12 rounded-full md:flex items-center justify-center bg-darker">
|
||||
{{ app.name.charAt(0) }}
|
||||
</div>
|
||||
</client-only>
|
||||
|
||||
{{ getAppCategory(app).icon ? `${getAppCategory(app).icon} ${app.name}` : app.name }}
|
||||
<div class="text-sm leading-5 font-bold">
|
||||
{{ app.text }}
|
||||
</div>
|
||||
<!-- app.lastUpdated: {{ app.lastUpdated }} -->
|
||||
<client-only v-if="app.lastUpdated">
|
||||
<small
|
||||
class="text-xs opacity-50"
|
||||
>
|
||||
<RelativeTime
|
||||
:timestamp="app.lastUpdated.timestamp"
|
||||
class="text-xs opacity-50"
|
||||
/>
|
||||
</small>
|
||||
<small
|
||||
slot="placeholder"
|
||||
class="text-xs opacity-50"
|
||||
>
|
||||
⏳
|
||||
</small>
|
||||
</client-only>
|
||||
|
||||
<!-- app.endpoint: {{ app.endpoint }} -->
|
||||
</template>
|
||||
|
||||
</a>
|
||||
|
||||
|
||||
<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-4">
|
||||
|
||||
<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-bold',
|
||||
// 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-bold',
|
||||
// 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>
|
||||
|
||||
</div>
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import scrollIntoView from 'scroll-into-view-if-needed'
|
||||
|
||||
import { getAppCategory } from '~/helpers/categories.js'
|
||||
import { getAppEndpoint } from '~/helpers/app-derived.js'
|
||||
// import appList from '~/static/app-list.json'
|
||||
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
// import RelativeTime from '~/components/relative-time.vue'
|
||||
import ListSummary from '~/components/list-summary.vue'
|
||||
|
||||
|
||||
export default {
|
||||
components: {
|
||||
// EmailSubscribe: () => process.client ? import('~/components/email-subscribe.vue') : null,
|
||||
ListSummary,
|
||||
LinkButton,
|
||||
RelativeTime: () => process.client ? import('~/components/relative-time.vue') : null
|
||||
},
|
||||
props: {
|
||||
appList: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
noEmailSubscribe: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
initialLimit: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
quickButtons: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{
|
||||
label: '✅ Full Native Support',
|
||||
query: 'status:native'
|
||||
},
|
||||
{
|
||||
label: '✳️ Rosetta',
|
||||
query: 'status:rosetta'
|
||||
},
|
||||
{
|
||||
label: '🚫 Unsupported',
|
||||
query: 'status:no'
|
||||
},
|
||||
{
|
||||
label: 'Music Tools',
|
||||
query: 'Music'
|
||||
},
|
||||
{
|
||||
label: 'Developer Tools',
|
||||
query: 'Developer'
|
||||
},
|
||||
{
|
||||
label: 'Photo Tools',
|
||||
query: 'Photo'
|
||||
},
|
||||
{
|
||||
label: 'Video Tools',
|
||||
query: 'Video'
|
||||
},
|
||||
{
|
||||
label: 'Productivity Tools',
|
||||
query: 'Productivity'
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
// appList,
|
||||
query: '',
|
||||
hasStartedAnyQuery: false,
|
||||
observer: null,
|
||||
seenItems: Object.fromEntries(this.appList.map(app => {
|
||||
return [app.slug, false]
|
||||
})),
|
||||
// results: [],
|
||||
titleStartsWithResults: [],
|
||||
titleContainsResults: [],
|
||||
categoryContainsResults: [],
|
||||
statusResults: [],
|
||||
// store: overlayStore.state
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
initialList () {
|
||||
return this.initialLimit !== null ? this.appList.slice(0, this.initialLimit) : this.appList
|
||||
},
|
||||
results () {
|
||||
if (!this.hasSearchInputText) return this.initialList
|
||||
|
||||
return [
|
||||
...this.titleStartsWithResults,
|
||||
...this.titleContainsResults,
|
||||
...this.categoryContainsResults,
|
||||
...this.statusResults
|
||||
]
|
||||
},
|
||||
// Chunk results to avoid having a parent node with more than 60 child nodes.
|
||||
chunkedResults () {
|
||||
|
||||
const results = [
|
||||
...this.results
|
||||
]
|
||||
|
||||
const size = 25
|
||||
const chunks = []
|
||||
|
||||
while (results.length > 0)
|
||||
chunks.push(results.splice(0, size))
|
||||
|
||||
return chunks
|
||||
},
|
||||
hasSearchInputText () {
|
||||
return this.query.length > 0
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.observer.disconnect()
|
||||
},
|
||||
// watch: {
|
||||
// 'store.mode': function (newMode) {
|
||||
// // If we're showing the search
|
||||
// // then focus on the search input
|
||||
// // on the next tick when our input
|
||||
// // exists
|
||||
// if (newMode === 'search') {
|
||||
// this.$nextTick(() => {
|
||||
// this.$refs.search.focus()
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
mounted () {
|
||||
// console.log(this.$el)
|
||||
|
||||
this.observer = new IntersectionObserver(this.onElementObserved, {
|
||||
// root: this.$el,
|
||||
threshold: 1.0,
|
||||
})
|
||||
|
||||
// Start observing all search rows
|
||||
this.initialList.forEach(app => {
|
||||
if (this.$refs.hasOwnProperty(`${app.slug}-row`) === false) {
|
||||
console.log('App Row not found', app)
|
||||
return
|
||||
}
|
||||
|
||||
// console.log('this.$refs[`${app.slug}-row`]', this.$refs[`${app.slug}-row`])
|
||||
this.observer.observe(this.$refs[`${app.slug}-row`][0])
|
||||
})
|
||||
|
||||
},
|
||||
methods: {
|
||||
getAppCategory,
|
||||
getAppEndpoint,
|
||||
getSearchLinks (app) {
|
||||
return app?.searchLinks || []
|
||||
},
|
||||
// Search priorities
|
||||
titleStartsWith (query, app) {
|
||||
const matches = app.name.toLowerCase().startsWith(query)
|
||||
if (matches) {
|
||||
this.titleStartsWithResults.push(app)
|
||||
}
|
||||
return matches
|
||||
},
|
||||
titleContains (query, app) {
|
||||
const matches = app.name.toLowerCase().includes(query)
|
||||
if (matches) {
|
||||
this.titleContainsResults.push(app)
|
||||
}
|
||||
return matches
|
||||
},
|
||||
categoryContains (query, app) {
|
||||
const matches = getAppCategory(app).label.toLowerCase().includes(query)
|
||||
if (matches) {
|
||||
this.categoryContainsResults.push(app)
|
||||
}
|
||||
return matches
|
||||
},
|
||||
statusIs (query, app) {
|
||||
|
||||
// console.log('query', query)
|
||||
|
||||
if (!query.includes('status:')) return
|
||||
|
||||
const [_, status] = query.split(':')
|
||||
|
||||
const matches = app.status.includes(status) || app.status === status
|
||||
|
||||
// if (matches) {
|
||||
// this.statusResults.push(app)
|
||||
// }
|
||||
|
||||
return matches
|
||||
},
|
||||
// Search tools
|
||||
pluck (array, index) {
|
||||
const pluckedItem = array[index]
|
||||
array.splice(index, 1)
|
||||
return pluckedItem
|
||||
},
|
||||
toggleFilter ( newFilterQuery ) {
|
||||
|
||||
// Get the key and value from our filter
|
||||
const [
|
||||
newFilterKey, // This will always have a value
|
||||
newFilterValue = null
|
||||
] = newFilterQuery.split(':')
|
||||
|
||||
const oldQueryWords = this.query.split(' ')
|
||||
|
||||
let oldHasStatus = false
|
||||
let oldHasWords = false
|
||||
|
||||
oldQueryWords.forEach( word => {
|
||||
if (word.includes('status:')) {
|
||||
oldHasStatus = true
|
||||
return
|
||||
}
|
||||
|
||||
if (word.trim().length !== 0) oldHasWords = true
|
||||
})
|
||||
|
||||
// If this filter is already present
|
||||
// then remove it
|
||||
if (this.query.includes(newFilterQuery)) {
|
||||
this.query = this.query.replace(newFilterQuery, '').trim()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// If there is only an existing status and we're adding a plain words
|
||||
// and the newQuery is not
|
||||
// then prepend the words to the existing status
|
||||
if (oldHasStatus && !oldHasWords && newFilterValue === null) {
|
||||
this.query = [
|
||||
newFilterQuery,
|
||||
this.query.trim()
|
||||
].join(' ')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// There is no filter key
|
||||
// then update the whole query
|
||||
if (newFilterValue === null) {
|
||||
this.query = newFilterQuery
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// However, if the query already has a status
|
||||
// then update the existing status
|
||||
if (this.query.includes(newFilterKey)) {
|
||||
|
||||
const queryWords = this.query.split(' ')
|
||||
|
||||
this.query = queryWords.map( word => {
|
||||
// If this the filter word
|
||||
// then update it to the new one
|
||||
if (word.includes(newFilterKey)) return newFilterQuery
|
||||
|
||||
return word.trim()
|
||||
}).join(' ')
|
||||
|
||||
return
|
||||
|
||||
|
||||
// Otherwise add to the end of our current query
|
||||
} else {
|
||||
const queryWords = []
|
||||
|
||||
// If the query is not empty
|
||||
// then add it to our updated query
|
||||
if (this.query.trim().length) queryWords.push(this.query.trim())
|
||||
|
||||
// Append the new filter
|
||||
queryWords.push(newFilterQuery)
|
||||
|
||||
// Update the query
|
||||
this.query = queryWords.join(' ')
|
||||
}
|
||||
},
|
||||
filterStatusFromText (rawQuery) {
|
||||
const statusText = []
|
||||
const searchWords = []
|
||||
|
||||
// Look through each word and separate the status words from the normal query words
|
||||
rawQuery.split(' ').forEach(word => {
|
||||
if (word.includes('status:')) {
|
||||
statusText.push(word)
|
||||
return
|
||||
}
|
||||
|
||||
searchWords.push(word)
|
||||
})
|
||||
|
||||
return [
|
||||
statusText.join(' '),
|
||||
searchWords.join(' ').trim()
|
||||
]
|
||||
},
|
||||
scrollInputToTop () {
|
||||
scrollIntoView(this.$refs['search'], {
|
||||
block: 'start',
|
||||
behavior: 'smooth'
|
||||
})
|
||||
},
|
||||
onElementObserved(entries) {
|
||||
entries.forEach(({ target, isIntersecting }) => {
|
||||
if (!isIntersecting) {
|
||||
return
|
||||
}
|
||||
|
||||
this.observer.unobserve(target)
|
||||
|
||||
// console.log('Observed target', target)
|
||||
|
||||
const appSlug = target.getAttribute('data-app-slug')
|
||||
|
||||
this.seenItems[appSlug] = true
|
||||
});
|
||||
},
|
||||
queryResults (rawQuery) {
|
||||
// Clear any results from before
|
||||
this.titleStartsWithResults = []
|
||||
this.titleContainsResults = []
|
||||
this.categoryContainsResults = []
|
||||
this.statusResults = []
|
||||
|
||||
|
||||
// Snap results scroll position back to top
|
||||
this.$refs['search-container'].scrollTop = 0
|
||||
|
||||
this.$emit('update:query', rawQuery)
|
||||
|
||||
|
||||
// If our query is empty
|
||||
// then bail
|
||||
if (rawQuery.length === 0) return
|
||||
|
||||
// Separate status filters from the actual query text
|
||||
const [
|
||||
statusText,
|
||||
query
|
||||
] = this.filterStatusFromText(rawQuery.toLowerCase())
|
||||
|
||||
|
||||
// Declare that at least one query has been made
|
||||
this.hasStartedAnyQuery = true
|
||||
|
||||
|
||||
// Search App List
|
||||
this.appList.filter( app => {
|
||||
const filters = [
|
||||
{
|
||||
key: 'status',
|
||||
method: this.statusIs,
|
||||
queryArg: statusText
|
||||
}
|
||||
]
|
||||
|
||||
// Does this app match every active filter
|
||||
const filtersMatched = filters.every( ({ key, method, queryArg }) => {
|
||||
// If the query does not contain the key for the filter
|
||||
// then filter automatically passes
|
||||
if (queryArg.includes(key) === false) return true
|
||||
|
||||
// console.log(queryArg, 'method', method)
|
||||
return method(queryArg, app)
|
||||
})
|
||||
|
||||
return filtersMatched
|
||||
}).forEach(app => {
|
||||
// console.log('app', app)
|
||||
|
||||
const matchers = [
|
||||
{
|
||||
method: this.titleStartsWith,
|
||||
queryArg: query
|
||||
},
|
||||
{
|
||||
method: this.titleContains,
|
||||
queryArg: query
|
||||
},
|
||||
{
|
||||
method: this.categoryContains,
|
||||
queryArg: query
|
||||
}
|
||||
]
|
||||
|
||||
// Run through our search priorities
|
||||
for (const { method, queryArg } of matchers){
|
||||
// iterations++
|
||||
const appMatches = method(queryArg, app)
|
||||
if (appMatches) {
|
||||
// We've found a match for this app
|
||||
// so let's stop trying match methods
|
||||
// and search the next app
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
// console.log('query', query)
|
||||
// console.log('iterations', iterations)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<template>
|
||||
|
||||
<small class="data-credit text-sm opacity-75 text-center mb-4">
|
||||
<span>Data generously provided by </span>
|
||||
<span>
|
||||
<a
|
||||
href="https://twitter.com/__tosh"
|
||||
class="font-bold"
|
||||
>Thomas Schranz</a>
|
||||
</span>
|
||||
<span>via</span>
|
||||
<span>
|
||||
<a
|
||||
href="https://applesilicongames.com/"
|
||||
class="font-bold"
|
||||
>Apple Silicon Games</a>
|
||||
</span>
|
||||
</small>
|
||||
|
||||
</template>
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
<template>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'h-8 transition-opacity duration-500 ease-in-out',
|
||||
visible ? 'opacity-100' : 'opacity-0'
|
||||
]"
|
||||
>
|
||||
|
||||
<a
|
||||
ref="follow"
|
||||
href="https://twitter.com/doesitarm?ref_src=twsrc%5Etfw"
|
||||
class="twitter-follow-button"
|
||||
data-show-count="false"
|
||||
>Follow @doesitarm</a>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { observeElementInViewport } from 'observe-element-in-viewport'
|
||||
|
||||
export default {
|
||||
data: function () {
|
||||
return {
|
||||
visible: false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// https://platform.twitter.com/widgets.js
|
||||
|
||||
|
||||
// Fallback reveal
|
||||
setInterval(() => {
|
||||
this.visible = true
|
||||
}, 5000)
|
||||
|
||||
// the returned function, when called, stops tracking the target element in the
|
||||
// given viewport
|
||||
const unobserve = observeElementInViewport(this.$refs.follow, async (_, unobserve, expandZoneElem) => {
|
||||
// On element enter viewport
|
||||
// console.log('Entered', logoRef)
|
||||
|
||||
this.loadTwitterScript()
|
||||
|
||||
// Turn off this observer
|
||||
unobserve()
|
||||
}, () => {
|
||||
// On element exit viewport
|
||||
// console.log('Exited', logoRef)
|
||||
}, {
|
||||
threshold: [ 0 ]
|
||||
})
|
||||
|
||||
},
|
||||
methods: {
|
||||
loadTwitterScript () {
|
||||
const twitterScript = document.createElement('script')
|
||||
|
||||
twitterScript.setAttribute('charset','utf-8')
|
||||
twitterScript.setAttribute('async','true')
|
||||
twitterScript.setAttribute('src','https://platform.twitter.com/widgets.js')
|
||||
|
||||
twitterScript.onload = () => {
|
||||
|
||||
// Delay reveal for dom update
|
||||
setInterval(() => {
|
||||
this.visible = true
|
||||
}, 850)
|
||||
}
|
||||
|
||||
document.head.appendChild(twitterScript)
|
||||
|
||||
|
||||
// Reveal after 200ms
|
||||
// setInterval(() => {
|
||||
// this.visible = true
|
||||
// }, 750)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<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="video.thumbnail.sizes"
|
||||
:data-srcset="video.thumbnail.srcset"
|
||||
type="image/jpg"
|
||||
>
|
||||
<img
|
||||
:data-src="video.thumbnail.src"
|
||||
: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'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
video: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
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>
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,445 +0,0 @@
|
|||
<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"
|
||||
@pointerover.once="warmConnections()"
|
||||
>
|
||||
<div
|
||||
v-if="playerLoaded === false"
|
||||
class="player-poster cursor-pointer"
|
||||
@click="startPlayerLoad()"
|
||||
>
|
||||
<picture
|
||||
class=""
|
||||
>
|
||||
<source
|
||||
v-for="(source, key) in posterSources"
|
||||
:key="key"
|
||||
:sizes="source.sizes"
|
||||
:data-srcset="source.srcset"
|
||||
:type="`image/${ key }`"
|
||||
>
|
||||
<img
|
||||
:data-src="video.thumbnail.src"
|
||||
:alt="video.name"
|
||||
class="absolute inset-0 h-full w-full object-cover lazyload"
|
||||
>
|
||||
</picture>
|
||||
<div
|
||||
class="video-card-overlay absolute inset-0 flex flex-col justify-center items-center bg-gradient-to-tr from-black to-transparent p-4"
|
||||
style="--gradient-from-color:rgba(0, 0, 0, 1); --gradient-to-color:rgba(0, 0, 0, 0.7);"
|
||||
>
|
||||
<div class="cover-top h-full">
|
||||
<slot name="cover-top">
|
||||
<!-- Top -->
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="play-circle bg-white-2 bg-blur flex justify-center items-center outline-0 rounded-full ease p-4">
|
||||
<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 class="cover-bottom h-full">
|
||||
|
||||
<slot name="cover-bottom">
|
||||
<!-- Bottom -->
|
||||
</slot>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
v-else
|
||||
ref="frame"
|
||||
:id="frameId"
|
||||
:src="`https://www.youtube-nocookie.com/embed/${video.id}?enablejsapi=1&autoplay=1&modestbranding=1&playsinline=1`"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- <pre>
|
||||
hasPlayer: {{ hasPlayer }}
|
||||
</pre> -->
|
||||
|
||||
<!-- <pre>
|
||||
player: {{ player }}
|
||||
</pre> -->
|
||||
|
||||
<!-- <pre>
|
||||
timstamps: {{ video.timestamps }}
|
||||
</pre> -->
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasTimestamps"
|
||||
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)"
|
||||
>{{ timestamp.fullText }}</button>
|
||||
</div>
|
||||
|
||||
<!-- activeTimestamp: {{ activeTimestamp }} -->
|
||||
<!-- playerTime: {{ playerTime }} -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import 'lazysizes'
|
||||
|
||||
import LinkButton from '~/components/link-button.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LinkButton
|
||||
},
|
||||
props: {
|
||||
video: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
playerLoaded: false,
|
||||
player: null,
|
||||
playing: false,
|
||||
progressInterval: null,
|
||||
playerTime: 0,
|
||||
preconnected: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
posterSources () {
|
||||
const webpSource = {
|
||||
...this.video.thumbnail,
|
||||
srcset: this.video.thumbnail.srcset.replaceAll('ytimg.com/vi/', 'ytimg.com/vi_webp/').replace(/.png|.jpg|.jpeg/g, '.webp')
|
||||
}
|
||||
|
||||
return {
|
||||
webp: webpSource,
|
||||
jpeg: this.video.thumbnail
|
||||
}
|
||||
},
|
||||
|
||||
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.detectAutoplay()
|
||||
.then( ({ willAutoplay }) => {
|
||||
// If we're allowed to autoplay
|
||||
// then start loading the player
|
||||
if ( willAutoplay === true ) {
|
||||
this.startPlayerLoad()
|
||||
}
|
||||
})
|
||||
},
|
||||
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' })
|
||||
},
|
||||
|
||||
async detectAutoplay () {
|
||||
|
||||
if ( !process.client ) return { willAutoplay: false }
|
||||
|
||||
const { default: canAutoPlay } = await import('can-autoplay')
|
||||
|
||||
const willAutoplay = await canAutoPlay.video()
|
||||
// const willAutoplayMuted = await canAutoPlay.video({ muted: true, inline: true })
|
||||
|
||||
return {
|
||||
willAutoplay: willAutoplay.result
|
||||
}
|
||||
},
|
||||
|
||||
async seekTo (timestampInSeconds) {
|
||||
|
||||
if (this.playerLoaded === false) {
|
||||
await this.startPlayerLoad()
|
||||
}
|
||||
|
||||
this.player.seekTo(timestampInSeconds)
|
||||
},
|
||||
|
||||
// async playVideo() {
|
||||
|
||||
// if (this.playerLoaded === false) {
|
||||
// await this.startPlayerLoad()
|
||||
// }
|
||||
|
||||
// this.$nextTick(() => {
|
||||
// // console.log('this.player', JSON.stringify(this.player))
|
||||
// this.player.playVideo()
|
||||
// })
|
||||
// },
|
||||
|
||||
addPrefetch(kind, url, as) {
|
||||
// console.log('prefetching', url)
|
||||
|
||||
const linkEl = document.createElement('link')
|
||||
|
||||
linkEl.rel = kind
|
||||
linkEl.href = url
|
||||
|
||||
if (as) {
|
||||
linkEl.as = as;
|
||||
}
|
||||
|
||||
document.head.append(linkEl)
|
||||
},
|
||||
|
||||
warmConnections() {
|
||||
if (this.preconnected) return
|
||||
|
||||
// The iframe document and most of its subresources come right off youtube.com
|
||||
this.addPrefetch('preconnect', 'https://www.youtube-nocookie.com')
|
||||
// The botguard script is fetched off from google.com
|
||||
this.addPrefetch('preconnect', 'https://www.google.com')
|
||||
|
||||
// Not certain if these ad related domains are in the critical path. Could verify with domain-specific throttling.
|
||||
this.addPrefetch('preconnect', 'https://googleads.g.doubleclick.net')
|
||||
this.addPrefetch('preconnect', 'https://static.doubleclick.net')
|
||||
|
||||
this.preconnected = true
|
||||
},
|
||||
|
||||
async startPlayerLoad () {
|
||||
this.playerLoaded = true
|
||||
|
||||
await this.initializePlayer()
|
||||
|
||||
// this.$nextTick(() => {
|
||||
// this.initializePlayer()
|
||||
// })
|
||||
},
|
||||
|
||||
async initializePlayer () {
|
||||
// console.log('Youtube Embed API Ready')
|
||||
|
||||
// Clear player
|
||||
this.player = null
|
||||
|
||||
// Clear progession interval
|
||||
clearInterval(this.progressInterval)
|
||||
|
||||
// If there are no timestamps
|
||||
// then stop
|
||||
if (!this.hasTimestamps) {
|
||||
this.playerLoaded = true
|
||||
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)
|
||||
|
||||
const onReady = () => new Promise( resolve => {
|
||||
|
||||
this.player = new YT.Player(this.$refs['frame'].id, {
|
||||
events: {
|
||||
'onReady': readyEvent => {
|
||||
this.onPlayerReady( readyEvent )
|
||||
|
||||
resolve( readyEvent )
|
||||
},
|
||||
'onStateChange': event => {
|
||||
// console.log('state changed', event)
|
||||
|
||||
const stateHandler = stateHandlers[String(event.data)]
|
||||
// console.log('stateHandler', stateHandler)
|
||||
stateHandler(event)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
await onReady()
|
||||
|
||||
// console.log('Youtube Player API ready', JSON.stringify(this.player))
|
||||
},
|
||||
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', event, this.player )
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue