Skip to content

Commit

Permalink
feat: Add description templating (#90)
Browse files Browse the repository at this point in the history
Co-authored-by: Evan Strat <[email protected]>
  • Loading branch information
evan10s and evan10s authored Feb 6, 2024
1 parent 653920b commit 46af4ff
Show file tree
Hide file tree
Showing 22 changed files with 413 additions and 114 deletions.
12 changes: 6 additions & 6 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ jobs:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Use Node.js 18.x
- name: Use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 20.x
cache: 'yarn'
cache-dependency-path: '**/yarn.lock'
- name: yarn install
Expand All @@ -39,10 +39,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js 18.x
- name: Use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 20.x
cache: 'yarn'
cache-dependency-path: '**/yarn.lock'
- name: yarn install
Expand All @@ -67,10 +67,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js 18.x
- name: Use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 20.x
cache: 'yarn'
cache-dependency-path: '**/yarn.lock'
- name: yarn install
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ COPY --from=run_server_prod /home/node/app/server/node_modules ./server/node_mod

WORKDIR /home/node/app/server/settings
COPY --chown=node:node server/settings/*.example.json .
COPY --chown=node:node server/settings/*.example.txt .

WORKDIR /home/node/app/server/env
COPY --chown=node:node server/env/production.env.example .
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ To get started:
3. YouTube API setup:
1. Scroll down to the bottom of the page to connect your YouTube channel. **Note:** This step can only be completed when the app is hosted on `localhost` or with a valid top-level domain due to Google OAuth2 app restrictions.
4. YouTube playlist mappings: If you have playlists that you'd like match videos added to, follow the instructions in this section to set this up.
5. Video description template: While no warning appears for this, you should double-check that the default video
description template fits your needs and adjust it as needed.

### Docker Compose setup in-depth

Expand Down Expand Up @@ -121,7 +123,7 @@ Project organization:
- [`client`](client) contains all frontend code (Vue3, Typescript)

Install some baseline dependencies:
- nodejs (v18 recommended)
- nodejs (version >= 20.0 required; Node 20 (LTS) is recommended)
- yarn

- To run the server: `cd server && yarn run dev`
Expand Down
31 changes: 27 additions & 4 deletions client/src/components/form/AutosavingTextInput.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
<template>
<VTextField v-model="inputValue"
<!-- TODO: Autosaving textarea should be either a separate component or this should be more flexible somehow -->
<VTextarea v-if="inputType === 'textarea'"
v-model="inputValue"
:label="label"
:disabled="state === State.LOADING || !!disabled"
persistent-hint
:hint="!!error ? error: (helpText || '')"
:error="!!error"
auto-grow
@blur="submit()"
>
<template v-if="state !== State.READY" v-slot:append>
<VIcon v-if="state === State.ERROR"
color="error"
class="mr-1"
>
mdi-alert-circle-outline
</VIcon>
<VIcon v-if="state === State.SUCCESS" color="success">mdi-check</VIcon>
<VProgressCircular v-if="state===State.LOADING" indeterminate />
</template>
</VTextarea>
<VTextField v-else
v-model="inputValue"
variant="underlined"
:disabled="state === State.LOADING || !!disabled"
:label="label"
Expand Down Expand Up @@ -45,8 +68,8 @@ interface IProps {
name: string;
label: string;
initialValue: string|undefined;
inputType: "text"|"password";
settingType: SettingType;
inputType: "text"|"password"|"textarea";
settingType: SettingType|"descriptionTemplate";
helpText?: string;
disabled?: boolean;
}
Expand Down Expand Up @@ -105,7 +128,7 @@ const calculatedInputType = computed(() => {
return "password";
}
return "text";
return props.inputType;
});
function togglePlaintext() {
Expand Down
46 changes: 26 additions & 20 deletions client/src/components/matches/MatchDescription.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,37 @@
>
{{ matchStore.descriptionFetchError }}
</VAlert>
<VBtn v-if="matchStore.selectedMatchKey"
class="mb-4"
variant="outlined"
prepend-icon="mdi-refresh"
:loading="matchStore.descriptionLoading"
:disabled="matchStore.uploadInProgress"
@click="matchStore.getSuggestedDescription()"
>
Regenerate description
</VBtn>
<div v-if="matchStore.selectedMatchKey">
<VBtn class="mb-4"
variant="outlined"
prepend-icon="mdi-refresh"
:loading="matchStore.descriptionLoading"
:disabled="matchStore.uploadInProgress"
@click="matchStore.getSuggestedDescription()"
>
Regenerate description
</VBtn>
<VAlert class="mb-4">
If you edit the description now, it'll only apply to the current match's videos. To revert your changes, press
<strong>Regenerate description</strong> above.
</VAlert>
<VTextarea v-model="matchStore.description"
label="Description"
:disabled="!matchStore.selectedMatchKey || matchStore.uploadInProgress || matchStore.descriptionLoading"
messages="Raw YouTube video description. Generated by server when you select a match."
auto-grow
class="mb-4"
/>
</div>
<VAlert v-else
color="warning"
variant="tonal"
class="mb-4"
>
Careful! If you enter a description before selecting a match, it will be overwritten by the
auto-generated description.
<p>
A custom description is generated for each match's videos using the template defined in
<RouterLink to="/settings">Settings</RouterLink>. You'll see a preview here after you select a match above.
</p>
</VAlert>
<VTextarea v-model="matchStore.description"
label="Description"
:disabled="matchStore.uploadInProgress || matchStore.descriptionLoading"
messages="Raw YouTube video description. Generated by server when you select a match."
auto-grow
class="mb-4"
/>
</template>
<script lang="ts" setup>
import {useMatchStore} from "@/stores/match";
Expand Down
41 changes: 39 additions & 2 deletions client/src/stores/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const useSettingsStore = defineStore("settings", () => {
const settings = ref<ISettings | null>(null);
// Whether secret values exist - not the actual secret values
const obfuscatedSecrets = ref<IObfuscatedSecrets | null>(null);
const descriptionTemplate = ref<string | null>(null);

const youTubeAuthState = ref<IYouTubeAuthState | null>(null);
const youTubeOAuth2RedirectUri = ref<string | null>(null);
Expand All @@ -28,7 +29,7 @@ export const useSettingsStore = defineStore("settings", () => {

async function getSettings(showLoading: boolean = true) {
loading.value = showLoading;
const settingsResult = await fetch("/api/v1/settings");
const settingsResult = await fetch("/api/v1/settings/values");

// Load settings separately from YouTube auth status and redirect URI
if (handleApiError(settingsResult, "Unable to load settings")) {
Expand All @@ -48,6 +49,21 @@ export const useSettingsStore = defineStore("settings", () => {
obfuscatedSecrets.value = await secretsResult.json();
}

const descriptionResult = await fetch("/api/v1/settings/descriptionTemplate");
if (handleApiError(descriptionResult, "Unable to load description template")) {
loading.value = false;
isFirstLoad.value = false;
return;
} else {
const result = await descriptionResult.json();
if (result.descriptionTemplate) {
descriptionTemplate.value = result.descriptionTemplate;
} else {
error.value = "No description template found in description template response";
console.error("No description template found in description template response:", result);
}
}

const [youtubeAuthStatusResult, youTubeOAuth2RedirectUriResult] = await Promise.all([
fetch("/api/v1/youtube/auth/status"),
fetch("/api/v1/youtube/auth/meta/redirectUri"),
Expand Down Expand Up @@ -79,7 +95,7 @@ export const useSettingsStore = defineStore("settings", () => {
await getSettings();
}

const submitResult = await fetch(`/api/v1/settings/${settingName}`, {
const submitResult = await fetch(`/api/v1/settings/values/${settingName}`, {
method: "POST",
body: JSON.stringify({
value,
Expand All @@ -103,11 +119,32 @@ export const useSettingsStore = defineStore("settings", () => {
return submitResult.ok;
}

async function saveDescriptionTemplate(value: string) {
const submitResult = await fetch("/api/v1/settings/descriptionTemplate", {
method: "POST",
body: JSON.stringify({
descriptionTemplate: value,
}),
headers: {
"Content-Type": "application/json",
},
});

if (!submitResult.ok) {
return `Error saving description template: ${submitResult.status} ${submitResult.statusText}`;
}

descriptionTemplate.value = value;
return submitResult.ok;
}

return {
descriptionTemplate,
error,
getSettings,
isFirstLoad,
loading,
saveDescriptionTemplate,
saveSetting,
settings,
obfuscatedSecrets,
Expand Down
2 changes: 1 addition & 1 deletion client/src/views/Home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<h2
class="mt-2 mb-2"
>
Video metadata
Video description
</h2>
<MatchDescription />
</VCol>
Expand Down
87 changes: 87 additions & 0 deletions client/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,86 @@
@on-choice-selected="saveUploadPrivacy"
/>

<h2 class="mb-1">Video description template</h2>
<VExpansionPanels class="mb-4">
<VExpansionPanel>
<VExpansionPanelTitle>Description template syntax/variables</VExpansionPanelTitle>
<VExpansionPanelText>
<ul class="ml-4">
<li>
This description template will be rendered using
<a href="https://mustache.github.io/mustache.5.html">Mustache</a>.
</li>
<li>Enclose variables (except URLs) inside <code v-pre>{{ two curly braces }}</code></li>
<li>
If a variable is noted as <code>(contains URL)</code>, it should be enclosed inside
<code v-pre>{{{ three curly braces }}}</code> so that Mustache won't escape them.
</li>
<li>
<code v-pre>{{! comment }}</code> - Two curly braces followed by an exclamation point denotes a
comment that will not be rendered in the final description.
</li>
</ul>
<br />
<p>Available variables:</p>
<ul class="ml-4">
<li>
<code>eventName</code> - Value of the event name setting (current value:
<code>{{ settingsStore.settings?.eventName ?? "Loading..." }}</code>)
</li>
<li>
<code>capitalizedVerboseMatchName</code> - the full form of the match name with the first letter of
each word capitalized (example: <code>Qualification Match 1</code> or
<code>Playoff Match 3 (R1)</code>)
</li>
<li>
<code>redTeams</code> - red alliance team numbers separated by a comma and a space (example:
<code>1234, 1234, 1234</code>)
</li>
<li>
<code>blueTeams</code> - blue alliance team numbers separated by a comma and a space (example:
<code>1234, 1234, 1234</code>)
</li>
<li><code>redScore</code> - red alliance match score (if available) (example: <code>21</code>)</li>
<li><code>blueScore</code> - blue alliance match score (if available) (example: <code>21</code>)</li>
<li>
<code>matchDetailsSite</code> - either <code>The Blue Alliance</code> or <code>FRC Events</code>
depending on the currently selected match data source
</li>
<li>
<code>matchUrl</code> <strong>(contains URL)</strong> - URL where full match results can be viewed
(links to The Blue Alliance or FRC Events depending on the currently selected match data source)
(example: <code>https://www.thebluealliance.com/match/2023gaalb_sf1m1</code> or
<code>https://frc-events.firstinspires.org/2023/gaalb/playoffs/3</code>)
</li>
<li>
<code>matchUploaderAttribution</code> <strong>(contains URL)</strong> - the text <code>Uploaded using
https://github.com/gafirst/match-uploader</code>
</li>
</ul>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
<VAlert v-if="matchStore.selectedMatchKey"
class="mb-4"
color="warning"
>
<p>
If you change the
description template here, you must press the <strong>Regenerate Description</strong> button on the
Upload page to update the description for the current match with the latest changes.
</p>
</VAlert>
<AutosavingTextInput :key="`descriptionTemplate-${dataRefreshKey}`"
:on-submit="saveDescriptionTemplate"
:initial-value="settingsStore.descriptionTemplate ?? undefined"
name="descriptionTemplate"
label="Template for YouTube video descriptions"
input-type="textarea"
setting-type="descriptionTemplate"
help-text="Updating this value will *not* affect any currently queued videos."
/>

<h2 class="mt-4">The Blue Alliance (TBA)</h2>
<h3>Read API</h3>
<AutosavingTextInput :key="`theBlueAllianceReadApiKey-${dataRefreshKey}`"
Expand Down Expand Up @@ -198,6 +278,7 @@
<VAlert v-if="settingsStore.youTubeAuthState?.accessTokenStored"
class="mb-3"
color="info"
variant="tonal"
>
You already have an active YouTube connection. Please use the Reset YouTube Connection button below to
adjust your YouTube OAuth2 client details.
Expand Down Expand Up @@ -243,6 +324,7 @@ import {PLAYOFF_BEST_OF_3, PLAYOFF_MATCH_TYPES} from "@/types/MatchType";
import AutosavingBtnSelectGroup from "@/components/form/AutosavingBtnSelectGroup.vue";
import {useSettingsStore} from "@/stores/settings";
import YouTubePlaylistMapping from "@/components/youtube/YouTubePlaylistMapping.vue";
import { useMatchStore } from "@/stores/match";
// const loading = ref(true);
const loading = computed(() => {
Expand All @@ -253,6 +335,7 @@ const loading = computed(() => {
const error = computed(() => {
return settingsStore.error;
});
const matchStore = useMatchStore();
const settingsStore = useSettingsStore();
// const settings = ref<ISettings | null>(null);
// const youTubeAuthState = ref<IYouTubeAuthState | null>(null);
Expand Down Expand Up @@ -297,6 +380,10 @@ async function submit(settingName: string, value: string | boolean, settingType:
return await settingsStore.saveSetting(settingName, value, settingType);
}
async function saveDescriptionTemplate(settingName: string, value: string, settingType: SettingType) {
return await settingsStore.saveDescriptionTemplate(value);
}
async function savePlayoffMatchType(value: string): Promise<void> {
savingPlayoffMatchType.value = true;
await submit("playoffsType", value, "setting");
Expand Down
Loading

0 comments on commit 46af4ff

Please sign in to comment.