Browse Source

Merge branch 'staging'

Owen Diffey 3 years ago
parent
commit
726010bf56

+ 2 - 0
.wiki/Configuration.md

@@ -41,6 +41,7 @@ Location: `backend/config/default.json`
 | `cookie.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
 | `cookie.SIDname` | Name of the cookie stored for sessions. |
 | `blacklistedCommunityStationNames ` | Array of blacklisted community station names. |
+| `featuredPlaylists ` | Array of featured playlist id's. Playlist privacy must be public. |
 | `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
 | `skipDbDocumentsVersionCheck` | Skips checking if there are any DB documents outdated or not. Should almost always be set to false. |
 | `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capure all jobs specified in `debug.captureJobs`. 
@@ -72,6 +73,7 @@ Location: `frontend/dist/config/default.json`
 | `siteSettings.logo_blue` | Path to the blue logo image, by default it is `/assets/blue_wordmark.png`. |
 | `siteSettings.sitename` | Should be the name of the site. |
 | `siteSettings.github` | URL of GitHub repository, defaults to `https://github.com/Musare/MusareNode`. |
+| `siteSettings.christmas` | Whether to enable christmas theming. |
 | `messages.accountRemoval` | Message to return to users on account removal. |
 | `shortcutOverrides` | Overwrite keyboard shortcuts, for example `"editSong.useAllDiscogs": { "keyCode": 68, "ctrl": true, "alt": true, "shift": false, "preventDefault": true }`. |
 | `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |

+ 6 - 3
backend/config/template.json

@@ -8,7 +8,7 @@
 	"serverPort": 8080,
 	"registrationDisabled": true,
 	"hideAutomaticallyRequestedSongs": false,
-    "hideAnonymousSongs": false,
+	"hideAnonymousSongs": false,
 	"sendDataRequestEmails": true,
 	"apis": {
 		"youtube": {
@@ -59,7 +59,10 @@
 		"secure": false,
 		"SIDname": "SID"
 	},
-	"blacklistedCommunityStationNames": ["musare"],
+	"blacklistedCommunityStationNames": [
+		"musare"
+	],
+	"featuredPlaylists": [],
 	"skipConfigVersionCheck": false,
 	"skipDbDocumentsVersionCheck": false,
 	"debug": {
@@ -92,5 +95,5 @@
 			]
 		}
 	},
-	"configVersion": 7
+	"configVersion": 8
 }

+ 1 - 1
backend/index.js

@@ -3,7 +3,7 @@ import "./loadEnvVariables.js";
 import util from "util";
 import config from "config";
 
-const REQUIRED_CONFIG_VERSION = 7;
+const REQUIRED_CONFIG_VERSION = 8;
 
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {

+ 50 - 0
backend/logic/actions/playlists.js

@@ -1,4 +1,5 @@
 import async from "async";
+import config from "config";
 
 import { isAdminRequired, isLoginRequired } from "./hooks";
 
@@ -530,6 +531,55 @@ export default {
 		);
 	}),
 
+	/**
+	 * Gets all playlists playlists
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	indexFeaturedPlaylists: isLoginRequired(async function indexMyPlaylists(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					const featuredPlaylistIds = config.get("featuredPlaylists");
+					if (featuredPlaylistIds.length === 0) next(true, []);
+					else next(null, featuredPlaylistIds);
+				},
+
+				(featuredPlaylistIds, next) => {
+					const featuredPlaylists = [];
+					async.eachLimit(
+						featuredPlaylistIds,
+						1,
+						(playlistId, next) => {
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+								.then(playlist => {
+									if (playlist.privacy === "public") featuredPlaylists.push(playlist);
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, featuredPlaylists);
+						}
+					);
+				}
+			],
+			async (err, playlists) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PLAYLIST_INDEX_FEATURED", `Indexing featured playlists failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PLAYLIST_INDEX_FEATURED", `Successfully indexed featured playlists.`);
+				return cb({
+					status: "success",
+					data: { playlists }
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Creates a new private playlist
 	 *

+ 1 - 1
backend/logic/db/schemas/station.js

@@ -42,7 +42,7 @@ export default {
 	owner: { type: String },
 	partyMode: { type: Boolean },
 	playMode: { type: String, enum: ["random", "sequential"], default: "random" },
-	theme: { type: String, enum: ["blue", "purple", "teal", "orange"], default: "blue" },
+	theme: { type: String, enum: ["blue", "purple", "teal", "orange", "red"], default: "blue" },
 	includedPlaylists: [{ type: String }],
 	excludedPlaylists: [{ type: String }],
 	documentVersion: { type: Number, default: 6, required: true }

+ 1 - 1
backend/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "musare-backend",
-  "version": "3.1.1",
+  "version": "3.2.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {

+ 1 - 1
backend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "musare-backend",
   "private": true,
-  "version": "3.1.1",
+  "version": "3.2.0",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",

+ 2 - 1
frontend/.eslintrc

@@ -40,6 +40,7 @@
 		"prettier/prettier": [
 			"error"
 		],
-		"vue/order-in-components": 2
+		"vue/order-in-components": 2,
+		"vue/no-v-for-template-key": 0
 	}
 }

+ 2 - 1
frontend/dist/config/template.json

@@ -22,7 +22,8 @@
 		"logo_white": "/assets/white_wordmark.png",
 		"logo_blue": "/assets/blue_wordmark.png",
 		"sitename": "Musare",
-		"github": "https://github.com/Musare/Musare"
+		"github": "https://github.com/Musare/Musare",
+		"christmas": false
 	},
 	"messages": {
 		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."

+ 1 - 1
frontend/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "musare-frontend",
-  "version": "3.1.1",
+  "version": "3.2.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {

+ 1 - 1
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.1.1",
+  "version": "3.2.0",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "author": "Musare Team",

+ 34 - 12
frontend/src/App.vue

@@ -12,6 +12,7 @@
 			<register-modal v-if="modals.register" />
 			<create-playlist-modal v-if="modals.createPlaylist" />
 		</div>
+		<falling-snow v-if="christmas" />
 	</div>
 </template>
 
@@ -38,7 +39,10 @@ export default {
 		CreatePlaylistModal: defineAsyncComponent(() =>
 			import("@/components/modals/CreatePlaylist.vue")
 		),
-		Banned: defineAsyncComponent(() => import("@/pages/Banned.vue"))
+		Banned: defineAsyncComponent(() => import("@/pages/Banned.vue")),
+		FallingSnow: defineAsyncComponent(() =>
+			import("@/components/FallingSnow.vue")
+		)
 	},
 	replace: false,
 	data() {
@@ -48,7 +52,8 @@ export default {
 			keyIsDown: false,
 			scrollPosition: { y: 0, x: 0 },
 			aModalIsOpen2: false,
-			broadcastChannel: null
+			broadcastChannel: null,
+			christmas: false
 		};
 	},
 	computed: {
@@ -227,6 +232,13 @@ export default {
 			this.changeNightmode(true);
 			this.enableNightmode();
 		}
+
+		lofig.get("siteSettings.christmas").then(christmas => {
+			if (christmas) {
+				this.christmas = true;
+				this.enableChristmasMode();
+			}
+		});
 	},
 	methods: {
 		toggleNightMode() {
@@ -254,6 +266,11 @@ export default {
 				.getElementsByTagName("body")[0]
 				.classList.remove("night-mode");
 		},
+		enableChristmasMode: () => {
+			document
+				.getElementsByTagName("body")[0]
+				.classList.add("christmas-mode");
+		},
 		...mapActions("modalVisibility", ["closeCurrentModal"]),
 		...mapActions("user/preferences", [
 			"changeNightmode",
@@ -286,6 +303,7 @@ export default {
 	--dark-orange: rgb(250, 50, 0);
 	--green: rgb(68, 189, 50);
 	--red: rgb(231, 77, 60);
+	--dark-red: rgb(235, 41, 19);
 	--white: rgb(255, 255, 255);
 	--black: rgb(0, 0, 0);
 	--light-grey: rgb(245, 245, 245);
@@ -357,6 +375,10 @@ export default {
 	}
 }
 
+.christmas-mode {
+	--primary-color: var(--red);
+}
+
 /* inter-regular - latin */
 @font-face {
 	font-family: "Inter";
@@ -481,7 +503,7 @@ export default {
 
 code {
 	background-color: var(--light-grey) !important;
-	color: var(--red) !important;
+	color: var(--dark-red) !important;
 }
 
 body.night-mode {
@@ -762,7 +784,7 @@ img {
 .alert {
 	padding: 20px;
 	color: var(--white);
-	background-color: var(--red);
+	background-color: var(--dark-red);
 	position: fixed;
 	top: 50px;
 	right: 50px;
@@ -867,7 +889,7 @@ img {
 }
 
 .tippy-box[data-theme~="confirm"] {
-	background-color: var(--red);
+	background-color: var(--dark-red);
 	border: 0;
 
 	.tippy-content {
@@ -935,7 +957,7 @@ img {
 
 	.stop-icon,
 	.delete-icon {
-		color: var(--red);
+		color: var(--dark-red);
 	}
 
 	.report-icon {
@@ -951,7 +973,7 @@ img {
 		}
 	}
 	&[data-theme~="confirm"] > .tippy-arrow::before {
-		border-top-color: var(--red);
+		border-top-color: var(--dark-red);
 	}
 }
 
@@ -964,7 +986,7 @@ img {
 		}
 	}
 	&[data-theme~="confirm"] > .tippy-arrow::before {
-		border-bottom-color: var(--red);
+		border-bottom-color: var(--dark-red);
 	}
 }
 
@@ -976,7 +998,7 @@ img {
 		}
 	}
 	&[data-theme~="confirm"] > .tippy-arrow::before {
-		border-left-color: var(--red);
+		border-left-color: var(--dark-red);
 	}
 }
 
@@ -988,7 +1010,7 @@ img {
 		}
 	}
 	&[data-theme~="confirm"] > .tippy-arrow::before {
-		border-right-color: var(--red);
+		border-right-color: var(--dark-red);
 	}
 }
 
@@ -1248,7 +1270,7 @@ button.delete:focus {
 	}
 
 	&.is-danger {
-		background-color: var(--red) !important;
+		background-color: var(--dark-red) !important;
 		border-width: 0;
 		color: var(--white);
 	}
@@ -1527,7 +1549,7 @@ h4.section-title {
 
 		.stop-icon,
 		.delete-icon {
-			color: var(--red);
+			color: var(--dark-red);
 		}
 
 		.report-icon {

+ 1 - 1
frontend/src/components/ActivityItem.vue

@@ -219,7 +219,7 @@ export default {
 		margin-left: 0px;
 
 		&.red {
-			background-color: var(--red);
+			background-color: var(--dark-red);
 		}
 
 		&.green {

+ 204 - 0
frontend/src/components/ChristmasLights.vue

@@ -0,0 +1,204 @@
+<template>
+	<div
+		:class="{
+			'christmas-lights': true,
+			loggedIn,
+			'christmas-lights-small': small
+		}"
+	>
+		<div class="christmas-wire"></div>
+		<template v-for="n in lights" :key="n">
+			<span class="christmas-light"></span>
+			<div class="christmas-wire"></div>
+		</template>
+	</div>
+</template>
+
+<script>
+import { mapState } from "vuex";
+
+export default {
+	props: {
+		small: { type: Boolean, default: false },
+		lights: { type: Number, default: 1 }
+	},
+	computed: {
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn
+		})
+	},
+
+	async mounted() {
+		this.christmas = await lofig.get("siteSettings.christmas");
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.christmas-mode {
+	.christmas-lights {
+		position: absolute;
+		width: 100%;
+		height: 50px;
+		left: 0;
+		top: 64px;
+		display: flex;
+		justify-content: space-around;
+		overflow: hidden;
+		pointer-events: none;
+
+		&.christmas-lights-small {
+			.christmas-light {
+				height: 28px;
+				width: 10px;
+
+				&::before {
+					width: 10px;
+					height: 10px;
+				}
+			}
+		}
+
+		.christmas-light {
+			height: 34px;
+			width: 12px;
+			border-top-left-radius: 50%;
+			border-top-right-radius: 50%;
+			border-bottom-left-radius: 50%;
+			border-bottom-right-radius: 50%;
+			z-index: 2;
+			animation: christmas-lights 30s ease infinite;
+
+			&::before {
+				content: "";
+				display: block;
+				width: 12px;
+				height: 12px;
+				background-color: rgb(6, 49, 19);
+				border-top-left-radius: 25%;
+				border-top-right-radius: 25%;
+				border-bottom-left-radius: 25%;
+				border-bottom-right-radius: 25%;
+			}
+
+			&:nth-of-type(1) {
+				transform: rotate(5deg);
+			}
+
+			&:nth-of-type(2) {
+				transform: rotate(-7deg);
+				animation-delay: -5s;
+			}
+
+			&:nth-of-type(3) {
+				transform: rotate(3deg);
+				animation-delay: -15s;
+			}
+
+			&:nth-of-type(4) {
+				transform: rotate(10deg);
+				animation-delay: -10s;
+			}
+
+			&:nth-of-type(5) {
+				transform: rotate(-3deg);
+				animation-delay: -20s;
+			}
+
+			&:nth-of-type(6) {
+				transform: rotate(8deg);
+				animation-delay: -65s;
+			}
+
+			&:nth-of-type(7) {
+				transform: rotate(-1deg);
+				animation-delay: -30s;
+			}
+
+			&:nth-of-type(8) {
+				transform: rotate(-4deg);
+				animation-delay: -75s;
+			}
+
+			&:nth-of-type(9) {
+				transform: rotate(3deg);
+				animation-delay: -60s;
+			}
+
+			&:nth-of-type(10) {
+				transform: rotate(-10deg);
+				animation-delay: -50s;
+			}
+
+			&:nth-of-type(11) {
+				transform: rotate(7deg);
+				animation-delay: -35s;
+			}
+
+			&:nth-of-type(12) {
+				transform: rotate(-3deg);
+				animation-delay: -70s;
+			}
+
+			&:nth-of-type(13) {
+				transform: rotate(2deg);
+				animation-delay: -25s;
+			}
+
+			&:nth-of-type(14) {
+				transform: rotate(9deg);
+				animation-delay: -45s;
+			}
+
+			&:nth-of-type(15) {
+				transform: rotate(-5deg);
+				animation-delay: -40s;
+			}
+		}
+
+		.christmas-wire {
+			flex: 1;
+			margin-bottom: 15px;
+			z-index: 1;
+
+			border-top: 2px solid var(--primary-color);
+			border-radius: 50%;
+			margin-left: -7px;
+			margin-right: -7px;
+			transform: scaleY(-1);
+			transform-origin: 0% 20%;
+		}
+	}
+}
+
+@keyframes christmas-lights {
+	0% {
+		background-color: magenta;
+		box-shadow: 0 5px 15px 3px rgba(255, 0, 255, 0.55);
+	}
+	17% {
+		background-color: cyan;
+		box-shadow: 0 5px 15px 3px rgba(0, 255, 255, 0.55);
+	}
+	34% {
+		background-color: lime;
+		box-shadow: 0 5px 15px 3px rgba(0, 255, 0, 0.55);
+	}
+	51% {
+		background-color: yellow;
+		box-shadow: 0 5px 15px 3px rgba(255, 255, 0, 0.55);
+	}
+	68% {
+		background-color: orange;
+		box-shadow: 0 5px 15px 3px rgba(255, 165, 0, 0.55);
+	}
+	85% {
+		background-color: red;
+		box-shadow: 0 5px 15px 3px rgba(255, 0, 0, 0.55);
+	}
+	100% {
+		background-color: magenta;
+		box-shadow: 0 5px 15px 3px rgba(255, 0, 255, 0.55);
+	}
+}
+</style>

+ 76 - 0
frontend/src/components/FallingSnow.vue

@@ -0,0 +1,76 @@
+<template>
+	<div class="winter-is-coming">
+		<div class="snow snow--near"></div>
+		<div class="snow snow--near snow--alt"></div>
+		<div class="snow snow--mid"></div>
+		<div class="snow snow--mid snow--alt"></div>
+		<div class="snow snow--far"></div>
+		<div class="snow snow--far snow--alt"></div>
+	</div>
+</template>
+
+<style lang="scss" scoped>
+.night-mode .winter-is-coming {
+	background: var(--black);
+}
+
+/*Snow courtesy of iamjamie on codepen.io (https://codepen.io/iamjamie/pen/wzbEXG)*/
+.winter-is-coming,
+.snow {
+	z-index: -1;
+	pointer-events: none;
+}
+
+.winter-is-coming {
+	overflow: hidden;
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background: rgb(240, 240, 240);
+}
+
+.snow {
+	position: absolute;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	animation: falling linear infinite both;
+	transform: translate3D(0, -100%, 0);
+}
+.snow--near {
+	animation-duration: 10s;
+	background-image: url("https://dl6rt3mwcjzxg.cloudfront.net/assets/snow/snow-large-075d267ecbc42e3564c8ed43516dd557.png");
+	background-size: contain;
+}
+.snow--near + .snow--alt {
+	animation-delay: 5s;
+}
+.snow--mid {
+	animation-duration: 20s;
+	background-image: url(https://dl6rt3mwcjzxg.cloudfront.net/assets/snow/snow-medium-0b8a5e0732315b68e1f54185be7a1ad9.png);
+	background-size: contain;
+}
+.snow--mid + .snow--alt {
+	animation-delay: 10s;
+}
+.snow--far {
+	animation-duration: 30s;
+	background-image: url(https://dl6rt3mwcjzxg.cloudfront.net/assets/snow/snow-small-1ecd03b1fce08c24e064ff8c0a72c519.png);
+	background-size: contain;
+}
+.snow--far + .snow--alt {
+	animation-delay: 15s;
+}
+
+@keyframes falling {
+	0% {
+		transform: translate3D(-7.5%, -100%, 0);
+	}
+	100% {
+		transform: translate3D(7.5%, 100%, 0);
+	}
+}
+</style>

+ 24 - 2
frontend/src/components/Modal.vue

@@ -15,6 +15,7 @@
 				<span class="delete material-icons" @click="closeCurrentModal()"
 					>highlight_off</span
 				>
+				<christmas-lights v-if="christmas" small :lights="5" />
 			</header>
 			<section class="modal-card-body">
 				<slot name="body" />
@@ -27,16 +28,33 @@
 </template>
 
 <script>
-import { mapActions } from "vuex";
+import { mapState, mapActions } from "vuex";
+import { defineAsyncComponent } from "vue";
 
 export default {
+	components: {
+		ChristmasLights: defineAsyncComponent(() =>
+			import("@/components/ChristmasLights.vue")
+		)
+	},
 	props: {
 		title: { type: String, default: "Modal" },
 		wide: { type: Boolean, default: false },
 		split: { type: Boolean, default: false }
 	},
-	mounted() {
+	data() {
+		return {
+			christmas: false
+		};
+	},
+	computed: {
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn
+		})
+	},
+	async mounted() {
 		this.type = this.toCamelCase(this.title);
+		this.christmas = await lofig.get("siteSettings.christmas");
 	},
 	methods: {
 		toCamelCase: str =>
@@ -226,4 +244,8 @@ export default {
 		}
 	}
 }
+
+.christmas-mode .modal .modal-card-head .christmas-lights {
+	top: 69px !important;
+}
 </style>

+ 23 - 2
frontend/src/components/layout/MainHeader.vue

@@ -66,14 +66,25 @@
 				</p>
 			</div>
 		</div>
+
+		<christmas-lights
+			v-if="siteSettings.christmas"
+			:lights="Math.min(Math.max(Math.floor(windowWidth / 175), 5), 15)"
+		/>
 	</nav>
 </template>
 
 <script>
 import Toast from "toasters";
 import { mapState, mapGetters, mapActions } from "vuex";
+import { defineAsyncComponent } from "vue";
 
 export default {
+	components: {
+		ChristmasLights: defineAsyncComponent(() =>
+			import("@/components/ChristmasLights.vue")
+		)
+	},
 	props: {
 		hideLogo: { type: Boolean, default: false },
 		transparent: { type: Boolean, default: false },
@@ -86,8 +97,10 @@ export default {
 			frontendDomain: "",
 			siteSettings: {
 				logo: "",
-				sitename: ""
-			}
+				sitename: "",
+				christmas: false
+			},
+			windowWidth: 0
 		};
 	},
 	computed: {
@@ -131,8 +144,16 @@ export default {
 
 		this.frontendDomain = await lofig.get("frontendDomain");
 		this.siteSettings = await lofig.get("siteSettings");
+
+		this.$nextTick(() => {
+			this.onResize();
+			window.addEventListener("resize", this.onResize);
+		});
 	},
 	methods: {
+		onResize() {
+			this.windowWidth = window.innerWidth;
+		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("user/auth", ["logout"]),
 		...mapActions("user/preferences", ["changeNightmode"])

+ 1 - 1
frontend/src/components/modals/EditSong/Tabs/Reports.vue

@@ -444,7 +444,7 @@ export default {
 		}
 
 		.unresolve-icon {
-			color: var(--red);
+			color: var(--dark-red);
 			cursor: pointer;
 		}
 	}

+ 1 - 1
frontend/src/components/modals/EditSong/index.vue

@@ -1845,7 +1845,7 @@ export default {
 		}
 
 		.duration-fill-button {
-			background-color: var(--red);
+			background-color: var(--dark-red);
 			color: var(--white);
 			width: 32px;
 			text-align: center;

+ 190 - 2
frontend/src/components/modals/ManageStation/Tabs/Playlists.vue

@@ -48,6 +48,188 @@
 				</button>
 			</div>
 			<div class="tab" v-show="tab === 'search'">
+				<div v-if="featuredPlaylists.length > 0">
+					<label class="label"> Featured playlists </label>
+					<playlist-item
+						v-for="featuredPlaylist in featuredPlaylists"
+						:key="`featuredKey-${featuredPlaylist._id}`"
+						:playlist="featuredPlaylist"
+						:show-owner="true"
+					>
+						<template #item-icon>
+							<i
+								class="material-icons"
+								v-if="
+									isAllowedToParty() &&
+									isSelected(featuredPlaylist._id)
+								"
+								content="This playlist is currently selected"
+								v-tippy
+							>
+								radio
+							</i>
+							<i
+								class="material-icons"
+								v-else-if="
+									isOwnerOrAdmin() &&
+									isPlaylistMode() &&
+									isIncluded(featuredPlaylist._id)
+								"
+								content="This playlist is currently included"
+								v-tippy
+							>
+								play_arrow
+							</i>
+							<i
+								class="material-icons excluded-icon"
+								v-else-if="
+									isOwnerOrAdmin() &&
+									isExcluded(featuredPlaylist._id)
+								"
+								content="This playlist is currently excluded"
+								v-tippy
+							>
+								block
+							</i>
+							<i
+								class="material-icons"
+								v-else
+								:content="
+									isPartyMode()
+										? 'This playlist is currently not selected or excluded'
+										: 'This playlist is currently not included or excluded'
+								"
+								v-tippy
+							>
+								play_disabled
+							</i>
+						</template>
+
+						<template #actions>
+							<i
+								v-if="isExcluded(featuredPlaylist._id)"
+								class="material-icons stop-icon"
+								content="This playlist is blacklisted in this station"
+								v-tippy="{ theme: 'info' }"
+								>play_disabled</i
+							>
+							<confirm
+								v-if="
+									isPartyMode() &&
+									isSelected(featuredPlaylist._id)
+								"
+								@confirm="
+									deselectPartyPlaylist(featuredPlaylist._id)
+								"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop playing songs from this playlist"
+									v-tippy
+								>
+									stop
+								</i>
+							</confirm>
+							<confirm
+								v-if="
+									isOwnerOrAdmin() &&
+									isPlaylistMode() &&
+									isIncluded(featuredPlaylist._id)
+								"
+								@confirm="
+									removeIncludedPlaylist(featuredPlaylist._id)
+								"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop playing songs from this playlist"
+									v-tippy
+								>
+									stop
+								</i>
+							</confirm>
+							<i
+								v-if="
+									isPartyMode() &&
+									!isSelected(featuredPlaylist._id) &&
+									!isExcluded(featuredPlaylist._id)
+								"
+								@click="selectPartyPlaylist(featuredPlaylist)"
+								class="material-icons play-icon"
+								content="Request songs from this playlist"
+								v-tippy
+								>play_arrow</i
+							>
+							<i
+								v-if="
+									isOwnerOrAdmin() &&
+									isPlaylistMode() &&
+									!isIncluded(featuredPlaylist._id) &&
+									!isExcluded(featuredPlaylist._id)
+								"
+								@click="includePlaylist(featuredPlaylist)"
+								class="material-icons play-icon"
+								:content="'Play songs from this playlist'"
+								v-tippy
+								>play_arrow</i
+							>
+							<confirm
+								v-if="
+									isOwnerOrAdmin() &&
+									!isExcluded(featuredPlaylist._id)
+								"
+								@confirm="
+									blacklistPlaylist(featuredPlaylist._id)
+								"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Blacklist Playlist"
+									v-tippy
+									>block</i
+								>
+							</confirm>
+							<confirm
+								v-if="
+									isOwnerOrAdmin() &&
+									isExcluded(featuredPlaylist._id)
+								"
+								@confirm="
+									removeExcludedPlaylist(featuredPlaylist._id)
+								"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop blacklisting songs from this playlist"
+									v-tippy
+								>
+									stop
+								</i>
+							</confirm>
+							<i
+								v-if="featuredPlaylist.createdBy === myUserId"
+								@click="showPlaylist(featuredPlaylist._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-if="
+									featuredPlaylist.createdBy !== myUserId &&
+									(featuredPlaylist.privacy === 'public' ||
+										isAdmin())
+								"
+								@click="showPlaylist(featuredPlaylist._id)"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</template>
+					</playlist-item>
+					<br />
+				</div>
 				<label class="label"> Search for a public playlist </label>
 				<div class="control is-grouped input-with-button">
 					<p class="control is-expanded">
@@ -655,7 +837,8 @@ export default {
 				count: 0,
 				resultsLeft: 0,
 				results: []
-			}
+			},
+			featuredPlaylists: []
 		};
 	},
 	computed: {
@@ -709,6 +892,11 @@ export default {
 				this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database
 			});
 
+			this.socket.dispatch("playlists.indexFeaturedPlaylists", res => {
+				if (res.status === "success")
+					this.featuredPlaylists = res.data.playlists;
+			});
+
 			this.socket.dispatch(
 				`stations.getStationIncludedPlaylistsById`,
 				this.station._id,
@@ -963,7 +1151,7 @@ export default {
 }
 
 .excluded-icon {
-	color: var(--red);
+	color: var(--dark-red);
 }
 
 .included-icon {

+ 13 - 1
frontend/src/components/modals/ManageStation/Tabs/Settings.vue

@@ -90,6 +90,14 @@
 								<i class="material-icons">palette</i>
 								Orange
 							</button>
+							<button
+								class="red"
+								v-if="station.theme !== 'red'"
+								@click="updateTheme('red')"
+							>
+								<i class="material-icons">palette</i>
+								Red
+							</button>
 						</template>
 					</tippy>
 				</div>
@@ -580,7 +588,7 @@ export default {
 			text-transform: capitalize;
 
 			&.red {
-				background-color: var(--red);
+				background-color: var(--dark-red);
 			}
 
 			&.green {
@@ -607,6 +615,10 @@ export default {
 				background-color: var(--teal);
 			}
 
+			&.red {
+				background-color: var(--dark-red);
+			}
+
 			i {
 				font-size: 20px;
 				margin-right: 4px;

+ 1 - 1
frontend/src/components/modals/ManageStation/index.vue

@@ -790,7 +790,7 @@ export default {
 						margin-left: auto;
 					}
 					&.skip-station {
-						color: var(--red);
+						color: var(--dark-red);
 					}
 					&.resume-station,
 					&.pause-station {

+ 1 - 1
frontend/src/components/modals/ViewReport.vue

@@ -333,7 +333,7 @@ export default {
 	}
 
 	.unresolve-icon {
-		color: var(--red);
+		color: var(--dark-red);
 		cursor: pointer;
 	}
 }

+ 5 - 1
frontend/src/pages/Admin/index.vue

@@ -1,5 +1,5 @@
 <template>
-	<div class="app">
+	<div class="app admin-area">
 		<main-header />
 		<div class="tabs is-centered">
 			<ul>
@@ -228,6 +228,10 @@ export default {
 </script>
 
 <style lang="scss">
+.christmas-mode .admin-area .christmas-lights {
+	top: 102px !important;
+}
+
 .main-container .container {
 	.button-row {
 		display: flex;

+ 1 - 1
frontend/src/pages/Admin/tabs/VerifiedSongs.vue

@@ -673,7 +673,7 @@ export default {
 		color: var(--green) !important;
 	}
 	.thumbDislike {
-		color: var(--red) !important;
+		color: var(--dark-red) !important;
 	}
 }
 

+ 29 - 1
frontend/src/pages/Home.vue

@@ -1,7 +1,7 @@
 <template>
 	<div>
 		<page-metadata title="Home" />
-		<div class="app">
+		<div class="app home-page">
 			<main-header
 				:hide-logo="true"
 				:transparent="true"
@@ -776,6 +776,34 @@ export default {
 };
 </script>
 
+<style lang="scss">
+.christmas-mode .home-page {
+	.header .overlay {
+		background: linear-gradient(
+			180deg,
+			rgba(231, 77, 60, 0.8) 0%,
+			rgba(231, 77, 60, 0.95) 31.25%,
+			rgba(231, 77, 60, 0.9) 54.17%,
+			rgba(231, 77, 60, 0.8) 100%
+		);
+	}
+	.christmas-lights {
+		top: 35vh !important;
+
+		&.loggedIn {
+			top: 20vh !important;
+		}
+	}
+	.header {
+		&,
+		.background,
+		.overlay {
+			border-radius: unset;
+		}
+	}
+}
+</style>
+
 <style lang="scss" scoped>
 * {
 	box-sizing: border-box;

+ 1 - 1
frontend/src/pages/Profile/index.vue

@@ -303,7 +303,7 @@ export default {
 		margin-left: 12px;
 
 		&.admin {
-			background-color: var(--red);
+			background-color: var(--dark-red);
 		}
 	}
 

+ 1 - 1
frontend/src/pages/ResetPassword.vue

@@ -534,7 +534,7 @@ p {
 		}
 
 		.error-icon {
-			color: var(--red);
+			color: var(--dark-red);
 		}
 
 		.success-icon,

+ 65 - 4
frontend/src/pages/Station/index.vue

@@ -198,16 +198,20 @@
 							<div id="seeker-bar-container">
 								<div
 									id="seeker-bar"
-									:style="{
-										width: `${seekerbarPercentage}%`
-									}"
 									:class="{
+										'christmas-seeker': christmas,
 										nyan:
 											currentSong &&
 											currentSong.youtubeId ===
 												'QH2-TGUlwu4'
 									}"
 								/>
+								<div
+									class="seeker-bar-cover"
+									:style="{
+										width: `calc(100% - ${seekerbarPercentage}%)`
+									}"
+								></div>
 								<img
 									v-if="
 										currentSong &&
@@ -280,6 +284,32 @@
 										'border-radius': '25px'
 									}"
 								/>
+								<img
+									v-if="
+										christmas &&
+										currentSong &&
+										![
+											'QH2-TGUlwu4',
+											'DtVBCG6ThDk',
+											'sI66hcu9fIs',
+											'iYYRH4apXDo',
+											'tRcPA7Fzebw',
+											'jofNR_WkoCE',
+											'l9PxOanFjxQ',
+											'xKVcVSYmesU',
+											'60ItHLz5WEA',
+											'e6vkFbtSGm0'
+										].includes(currentSong.youtubeId)
+									"
+									src="https://openclipart.org/image/800px/312117"
+									:style="{
+										position: 'absolute',
+										top: `-30px`,
+										left: `calc(${seekerbarPercentage}% - 25px)`,
+										height: '50px',
+										transform: 'scaleX(-1)'
+									}"
+								/>
 							</div>
 							<div id="control-bar-container">
 								<div id="left-buttons">
@@ -908,7 +938,8 @@ export default {
 			socketConnected: null,
 			persistentToastCheckerInterval: null,
 			persistentToasts: [],
-			partyPlaylistLock: false
+			partyPlaylistLock: false,
+			christmas: false
 		};
 	},
 	computed: {
@@ -1041,6 +1072,8 @@ export default {
 
 		this.frontendDevMode = await lofig.get("mode");
 
+		this.christmas = await lofig.get("siteSettings.christmas");
+
 		this.socket.dispatch(
 			"stations.existsByName",
 			this.stationIdentifier,
@@ -2570,6 +2603,15 @@ export default {
 					left: 0;
 					bottom: 0;
 					position: absolute;
+					width: 100%;
+				}
+
+				.seeker-bar-cover {
+					position: absolute;
+					top: 0;
+					right: 0;
+					bottom: 0;
+					background-color: inherit;
 				}
 			}
 
@@ -2814,6 +2856,25 @@ export default {
 	}
 }
 
+.christmas-seeker {
+	background: repeating-linear-gradient(
+		-45deg,
+		var(--white),
+		var(--white) 1rem,
+		var(--dark-red) 1rem,
+		var(--dark-red) 2rem
+	);
+
+	background-size: 150% 200%;
+	animation: christmas 10s linear infinite;
+}
+
+@keyframes christmas {
+	100% {
+		background-position: 100% 100%;
+	}
+}
+
 .bg-bubbles {
 	top: 0;
 	left: 0;