Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/scripts/check-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { themes, ThemeSchema, ThemesList } from "../src/ts/constants/themes";
import { z } from "zod";
import { ChallengeSchema, Challenge } from "@monkeytype/schemas/challenges";
import { LayoutObject, LayoutObjectSchema } from "@monkeytype/schemas/layouts";
import { QuoteDataSchema, QuoteData } from "@monkeytype/schemas/quotes";
import { QuoteDataBaseSchema, QuoteData } from "@monkeytype/schemas/quotes";

class Problems<K extends string, T extends string> {
private type: string;
Expand Down Expand Up @@ -185,7 +185,7 @@ async function validateQuotes(): Promise<void> {
}

//check schema
const schema = QuoteDataSchema.extend({
const schema = QuoteDataBaseSchema.extend({
language: LanguageSchema
//icelandic only exists as icelandic_1k, language in quote file is stripped of its size
.or(z.literal("icelandic")),
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/html/pages/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@
</button>
</div>

<div class="spacer quoteTagSpacer"></div>

<div class="quoteTagFilter hidden">
<button
class="textButton quoteTagFilterTrigger"
id="quoteTagFilterTrigger"
aria-haspopup="true"
aria-expanded="false"
aria-controls="quoteTagFilterModal"
title="Filter by tag"
>
<i class="fas fa-fw fa-tags"></i>
<span class="quoteTagFilterLabel">Select tags</span>
</button>
</div>

<div class="zen">
<div
class="textButton"
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/html/popups.html
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,31 @@
<button class="saveButton">save</button>
</div>
</dialog>

<!-- Tag modal -->
<dialog id="quoteTagFilterModal" class="modalWrapper hidden">
<div class="modal">
<div class="quoteTagFilterDropdownHeader">
<span class="quoteTagFilterDropdownTitle">Filter by tag</span>
<button
type="button"
class="textButton quoteTagFilterClearBtn"
id="quoteTagFilterModalClearBtn"
title="Clear all tag filters"
>
clear
</button>
</div>

<div
class="quoteTagFilterPills"
id="quoteTagFilterModalPills"
role="group"
aria-label="Quote tags"
></div>
</div>
</dialog>

<dialog id="editPresetModal" class="modalWrapper hidden">
<form class="modal">
<div class="title popupTitle"></div>
Expand Down
157 changes: 157 additions & 0 deletions frontend/src/styles/test.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1662,3 +1662,160 @@ body.fb-arrows {
}
}
}

// Quote Tag Filter
#testConfig {
// Register .quoteTagFilter alongside the other config sections so it gets
// the same grid-auto-flow and textButton padding treatment.
.quoteTagFilter {
display: grid;
grid-auto-flow: column;
justify-content: end;
position: relative; // anchor for the absolute dropdown

.textButton {
padding: var(--verticalPadding) var(--horizontalPadding);

&:first-child {
margin-left: var(--horizontalPadding);
}
&:last-child {
margin-right: var(--horizontalPadding);
}
}
}
}

// Trigger button extras
.quoteTagFilterTrigger {
display: flex;
align-items: center;
gap: 0.35em;
white-space: nowrap;
}

.quoteTagFilterChevron {
font-size: 0.7em;
opacity: 0.5;
transition:
transform 0.2s ease,
opacity 0.2s ease;
}

.quoteTagFilterTrigger[aria-expanded="true"] .quoteTagFilterChevron {
transform: rotate(180deg);
opacity: 1;
}

// Dropdown panel
.quoteTagFilterDropdown {
position: absolute;
// Sit just below the config row; z-index keeps it above #words
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%) translateY(-4px);
z-index: 200;

min-width: 240px;
padding: 0.6rem;
border-radius: var(--roundness);

background-color: var(--sub-alt-color);
border: 1px solid color-mix(in srgb, var(--sub-color) 40%, transparent);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);

// Hidden by default β€” .open class toggled by JS
opacity: 0;
pointer-events: none;
transition:
opacity 0.15s ease,
transform 0.15s ease;

&.open {
opacity: 1;
pointer-events: all;
transform: translateX(-50%) translateY(0);
}
}

// Dropdown header
.quoteTagFilterDropdownHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid color-mix(in srgb, var(--sub-color) 30%, transparent);
}

.quoteTagFilterDropdownTitle {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--sub-color);
}

// Clear button β€” compact variant of .textButton
.quoteTagFilterClearBtn {
font-size: 0.7rem;
padding: 0.15em 0.5em;
color: var(--sub-color);
visibility: hidden; // shown by JS only when tags are selected
transition: color 0.125s;

&:hover {
color: var(--text-color);
}
}

// Tag pills
.quoteTagFilterPills {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}

// Each pill reuses .textButton as a base class (applied in JS).
// Additional pill-specific overrides live here.
.quoteTagPill {
display: inline-flex;
align-items: center;
gap: 0.3em;
padding: 0.25em 0.65em;
border-radius: 999px; // pill shape
font-size: 0.75rem;
opacity: 0.5;
transition:
background-color 0.125s,
color 0.125s,
opacity 0.125s;

i {
font-size: 0.7em;
}

&:hover {
opacity: 0.85;
}

// Active state mirrors MonkeyType's selected config button pattern
&.active {
opacity: 1;
color: var(--main-color);
background-color: color-mix(in srgb, var(--main-color) 12%, transparent);
}

&.disabled {
opacity: 0.2;
cursor: not-allowed;
pointer-events: none;
filter: grayscale(1);
}
}

// Responsive
@media (max-width: 480px) {
.quoteTagFilterDropdown {
min-width: 190px;
}
}
8 changes: 8 additions & 0 deletions frontend/src/ts/commandline/commandline-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,14 @@ export const commandlineConfigMetadata: CommandlineConfigMetadataObject = {
},
},
},
quoteTags: {
subgroup: {
options: "fromSchema",
afterExec: () => {
TestLogic.restart();
},
},
},
//behavior
difficulty: {
subgroup: {
Expand Down
24 changes: 19 additions & 5 deletions frontend/src/ts/config/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as ConfigSchemas from "@monkeytype/schemas/configs";
import { roundTo1 } from "@monkeytype/util/numbers";
import { capitalizeFirstLetter } from "../utils/strings";
import { getDefaultConfig } from "../constants/default-config";
import { areUnsortedArraysEqual } from "../utils/arrays";
// type SetBlock = {
// [K in keyof ConfigSchemas.Config]?: ConfigSchemas.Config[K][];
// };
Expand Down Expand Up @@ -170,13 +171,26 @@ export const configMetadata: ConfigMetadataObject = {
displayString: "quote length",
changeRequiresRestart: true,
group: "test",
overrideConfig: ({ currentConfig }) => {
overrideConfig: ({ value, currentConfig }) => {
const overrides: Partial<ConfigSchemas.Config> = {};
if (
!areUnsortedArraysEqual(value as number[], currentConfig.quoteLength)
) {
overrides.quoteTags = [];
}
if (currentConfig.mode !== "quote") {
return {
mode: "quote",
};
overrides.mode = "quote";
}
return {};
return overrides;
},
},
quoteTags: {
icon: "fa-tags",
displayString: "quote tags",
changeRequiresRestart: false,
group: "test",
overrideValue: ({ value }) => {
return [...new Set(value)];
},
},
language: {
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/ts/config/setters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ import { typedKeys, triggerResize, escapeHTML } from "../utils/misc";
import { camelCaseToWords, capitalizeFirstLetter } from "../utils/strings";
import { Config, setConfigStore } from "./store";
import { FunboxName } from "@monkeytype/schemas/configs";
import { type QuoteTag } from "@monkeytype/schemas/quotes";

export function setQuoteTags(tags: QuoteTag[], nosave?: boolean): boolean {
return setConfig("quoteTags", tags, {
nosave,
});
}

export function toggleQuoteTag(tag: QuoteTag, nosave?: boolean): void {
const current = [...Config.quoteTags];
const idx = current.indexOf(tag);

if (idx === -1) {
current.push(tag);
} else {
current.splice(idx, 1);
}

setQuoteTags(current, nosave);
}

export function setConfig<T extends keyof ConfigSchemas.Config>(
key: T,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/ts/constants/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const obj: Config = {
time: 30,
mode: "time",
quoteLength: [1],
quoteTags: [],
language: "english",
fontSize: 2,
freedomMode: false,
Expand Down
40 changes: 35 additions & 5 deletions frontend/src/ts/controllers/quotes-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import * as DB from "../db";
import Ape from "../ape";
import { tryCatch } from "@monkeytype/util/trycatch";
import { Language } from "@monkeytype/schemas/languages";
import { QuoteData, QuoteDataQuote } from "@monkeytype/schemas/quotes";
import {
QuoteData,
QuoteDataQuote,
QuoteTag,
} from "@monkeytype/schemas/quotes";
import { RequiredProperties } from "../utils/misc";

export type Quote = QuoteDataQuote & {
Expand Down Expand Up @@ -79,6 +83,7 @@ class QuotesController {
length: quote.length,
id: quote.id,
language: data.language,
tags: quote.tags,
group: 0,
};

Expand Down Expand Up @@ -107,6 +112,16 @@ class QuotesController {
return this.quoteCollection;
}

getAvailableTags(quoteGroups: number[]): Set<QuoteTag> {
const tags = new Set<QuoteTag>();
quoteGroups.forEach((group) => {
this.quoteCollection.groups[group]?.forEach((quote) => {
quote.tags?.forEach((tag) => tags.add(tag));
});
});
return tags;
}

getQuoteById(id: number): Quote | undefined {
const targetQuote = this.quoteCollection.quotes.find((quote: Quote) => {
return quote.id === id;
Expand Down Expand Up @@ -148,7 +163,10 @@ class QuotesController {
return randomQuote;
}

getRandomFavoriteQuote(language: Language): Quote | null {
getRandomFavoriteQuote(
language: Language,
tagFilter?: string[],
): Quote | null {
const snapshot = DB.getSnapshot();
if (!snapshot) {
return null;
Expand All @@ -174,10 +192,22 @@ class QuotesController {
return null;
}

const randomQuoteId = randomElementFromArray(quoteIds);
const randomQuote = this.getQuoteById(parseInt(randomQuoteId, 10));
const selectedTags =
(tagFilter?.length ?? 0) > 0 ? new Set(tagFilter) : null;

const matchingQuotes =
selectedTags === null
? quoteIds
.map((quoteId) => this.getQuoteById(parseInt(quoteId, 10)))
.filter((q): q is Quote => q !== undefined)
: quoteIds
.map((quoteId) => this.getQuoteById(parseInt(quoteId, 10)))
.filter((q): q is Quote => q !== undefined)
.filter((q) => (q.tags ?? []).some((t) => selectedTags.has(t)));

if (matchingQuotes.length === 0) return null;

return randomQuote ?? null;
return randomElementFromArray(matchingQuotes) ?? null;
}

isQuoteFavorite({ language: quoteLanguage, id }: Quote): boolean {
Expand Down
Loading
Loading