mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Implement Deck/Notetype selectors in Svelte
This commit is contained in:
parent
fbb9e606eb
commit
25f51345d5
8 changed files with 343 additions and 2 deletions
29
ts/lib/components/DeckChooser.svelte
Normal file
29
ts/lib/components/DeckChooser.svelte
Normal file
|
@ -0,0 +1,29 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { mdiBookOutline } from "./icons";
|
||||
import { getDeckNames } from "@generated/backend";
|
||||
import ItemChooser from "./ItemChooser.svelte";
|
||||
import type { DeckNameId } from "@generated/anki/decks_pb";
|
||||
|
||||
let decks: DeckNameId[] = $state([]);
|
||||
let selectedDeck: DeckNameId | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
getDeckNames({ skipEmptyDefault: true, includeFiltered: false }).then(
|
||||
(response) => {
|
||||
decks = response.entries;
|
||||
},
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<ItemChooser
|
||||
title="Choose Deck"
|
||||
searchPlaceholder="Search decks..."
|
||||
bind:selectedItem={selectedDeck}
|
||||
items={decks}
|
||||
icon={mdiBookOutline}
|
||||
/>
|
240
ts/lib/components/ItemChooser.svelte
Normal file
240
ts/lib/components/ItemChooser.svelte
Normal file
|
@ -0,0 +1,240 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { magnifyIcon, mdiClose } from "./icons";
|
||||
import Icon from "./Icon.svelte";
|
||||
import IconConstrain from "./IconConstrain.svelte";
|
||||
import LabelButton from "./LabelButton.svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
import type { IconData } from "./types";
|
||||
|
||||
interface Item {
|
||||
id: bigint;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
searchPlaceholder: string;
|
||||
selectedItem?: Item | null;
|
||||
items: Item[];
|
||||
icon: IconData;
|
||||
onChange?: (item: Item) => void;
|
||||
}
|
||||
|
||||
let { title, searchPlaceholder, onChange, icon, items, selectedItem = $bindable(null) }: Props = $props();
|
||||
let modal: Modal | null = $state(null);
|
||||
let searchQuery = $state("");
|
||||
|
||||
const filteredItems = $derived(
|
||||
searchQuery.trim() === ""
|
||||
? items
|
||||
: items.filter((item) =>
|
||||
item.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
function onSelect(item: Item) {
|
||||
selectedItem = item;
|
||||
onChange?.(item);
|
||||
modal?.hide();
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
searchQuery = "";
|
||||
modal?.show();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!selectedItem && items.length > 0) {
|
||||
selectedItem = items[0];
|
||||
onChange?.(selectedItem);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<LabelButton on:click={openModal} class="chooser-button">
|
||||
{selectedItem?.name ?? "…"}
|
||||
</LabelButton>
|
||||
|
||||
<Modal bind:this={modal} dialogClass="modal-lg">
|
||||
<div slot="header" class="modal-header">
|
||||
<IconConstrain iconSize={90}>
|
||||
<Icon {icon} />
|
||||
</IconConstrain>
|
||||
<h5 class="modal-title">{title}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div slot="body" class="modal-body">
|
||||
<div class="search-container">
|
||||
<div class="search-input-wrapper">
|
||||
<div class="search-icon">
|
||||
<IconConstrain iconSize={70}>
|
||||
<Icon icon={magnifyIcon} />
|
||||
</IconConstrain>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
class="clear-search"
|
||||
onclick={() => (searchQuery = "")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<IconConstrain iconSize={60}>
|
||||
<Icon icon={mdiClose} />
|
||||
</IconConstrain>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-grid">
|
||||
{#each filteredItems as item (item.id)}
|
||||
<button
|
||||
class="item-card"
|
||||
class:selected={selectedItem?.id === item.id}
|
||||
onclick={() => onSelect(item)}
|
||||
aria-label="Select {item.name}"
|
||||
>
|
||||
<h6 class="item-title">{item.name}</h6>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
@use "../sass/button-mixins" as button;
|
||||
|
||||
:global(.label-button.chooser-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.375rem;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--fg-subtle);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem 0.5rem 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-search {
|
||||
padding: 0.25rem;
|
||||
margin-right: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg-subtle);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--canvas-inset);
|
||||
color: var(--fg);
|
||||
}
|
||||
}
|
||||
|
||||
.item-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
:global(.item-card) {
|
||||
@include button.base(
|
||||
$border: true,
|
||||
$with-hover: true,
|
||||
$with-active: true,
|
||||
$with-disabled: false
|
||||
);
|
||||
@include button.border-radius;
|
||||
|
||||
padding: 1rem;
|
||||
text-align: start;
|
||||
background: var(--canvas-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
|
||||
&:hover {
|
||||
background: var(--canvas-inset);
|
||||
border-color: var(--border);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--canvas-elevated);
|
||||
}
|
||||
</style>
|
|
@ -11,7 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
import { modalsKey } from "./context-keys";
|
||||
|
||||
export let modalKey: string = Math.random().toString(36).substring(2);
|
||||
export const dialogClass: string = "";
|
||||
export let dialogClass: string = "";
|
||||
export let onOkClicked: (() => void) | undefined = undefined;
|
||||
export let onCancelClicked: (() => void) | undefined = undefined;
|
||||
export let onShown: (() => void) | undefined = undefined;
|
||||
|
|
28
ts/lib/components/NotetypeChooser.svelte
Normal file
28
ts/lib/components/NotetypeChooser.svelte
Normal file
|
@ -0,0 +1,28 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { NotetypeNameId } from "@generated/anki/notetypes_pb";
|
||||
|
||||
import { mdiNewspaper } from "./icons";
|
||||
import { getNotetypeNames } from "@generated/backend";
|
||||
import ItemChooser from "./ItemChooser.svelte";
|
||||
|
||||
let notetypes: NotetypeNameId[] = $state([]);
|
||||
let selectedNotetype: NotetypeNameId | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
getNotetypeNames({}).then((response) => {
|
||||
notetypes = response.entries;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<ItemChooser
|
||||
title="Choose Note Type"
|
||||
searchPlaceholder="Search note types..."
|
||||
bind:selectedItem={selectedNotetype}
|
||||
items={notetypes}
|
||||
icon={mdiNewspaper}
|
||||
/>
|
|
@ -15,6 +15,8 @@ import AlignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?compone
|
|||
import alignVerticalCenter_ from "@mdi/svg/svg/align-vertical-center.svg?url";
|
||||
import AlignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?component";
|
||||
import alignVerticalTop_ from "@mdi/svg/svg/align-vertical-top.svg?url";
|
||||
import BookOutline_ from "@mdi/svg/svg/book-outline.svg?component";
|
||||
import bookOutline_ from "@mdi/svg/svg/book-outline.svg?url";
|
||||
import CheckCircle_ from "@mdi/svg/svg/check-circle.svg?component";
|
||||
import checkCircle_ from "@mdi/svg/svg/check-circle.svg?url";
|
||||
import ChevronDown_ from "@mdi/svg/svg/chevron-down.svg?component";
|
||||
|
@ -111,6 +113,8 @@ import Math_ from "@mdi/svg/svg/math-integral-box.svg?component";
|
|||
import math_ from "@mdi/svg/svg/math-integral-box.svg?url";
|
||||
import NewBox_ from "@mdi/svg/svg/new-box.svg?component";
|
||||
import newBox_ from "@mdi/svg/svg/new-box.svg?url";
|
||||
import Newspaper_ from "@mdi/svg/svg/newspaper.svg?component";
|
||||
import newspaper_ from "@mdi/svg/svg/newspaper.svg?url";
|
||||
import Paperclip_ from "@mdi/svg/svg/paperclip.svg?component";
|
||||
import paperclip_ from "@mdi/svg/svg/paperclip.svg?url";
|
||||
import RectangleOutline_ from "@mdi/svg/svg/rectangle-outline.svg?component";
|
||||
|
@ -288,3 +292,5 @@ export const mdiVectorPolygonVariant = { url: vectorPolygonVariant_, component:
|
|||
export const incrementClozeIcon = { url: incrementCloze_, component: IncrementCloze_ };
|
||||
export const mdiEarth = { url: earth_, component: Earth_ };
|
||||
export const caretDownFill = { url: caretDownFill_, component: CaretDownFill_ };
|
||||
export const mdiNewspaper = { url: newspaper_, component: Newspaper_ };
|
||||
export const mdiBookOutline = { url: bookOutline_, component: BookOutline_ };
|
||||
|
|
|
@ -61,7 +61,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
<button type="button" class="btn btn-secondary" on:click={modal.cancelHandler}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" on:click={modal.acceptHandler}>OK</button>
|
||||
<button type="button" class="btn btn-primary" on:click={modal.acceptHandler}>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
|
|
@ -720,6 +720,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
import { HelpPageLinkRequest_HelpPage } from "@generated/anki/links_pb";
|
||||
import { MessageBoxType } from "@generated/anki/frontend_pb";
|
||||
import type Modal from "$lib/components/Modal.svelte";
|
||||
import EditorChoosers from "./editor-toolbar/EditorChoosers.svelte";
|
||||
|
||||
$: isIOImageLoaded = false;
|
||||
$: ioImageLoadedStore.set(isIOImageLoaded);
|
||||
|
@ -1195,6 +1196,8 @@ components and functionality for general note editing.
|
|||
on:dragover={preventDefaultIfNonLegacy}
|
||||
on:drop={checkNonLegacy(handlePickerDrop)}
|
||||
>
|
||||
<EditorChoosers />
|
||||
|
||||
<EditorToolbar {size} {wrap} api={toolbar}>
|
||||
<svelte:fragment slot="notetypeButtons">
|
||||
{#if mode === "browser"}
|
||||
|
|
33
ts/routes/editor/editor-toolbar/EditorChoosers.svelte
Normal file
33
ts/routes/editor/editor-toolbar/EditorChoosers.svelte
Normal file
|
@ -0,0 +1,33 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import NotetypeChooser from "$lib/components/NotetypeChooser.svelte";
|
||||
import DeckChooser from "$lib/components/DeckChooser.svelte";
|
||||
</script>
|
||||
|
||||
<div class="top-bar">
|
||||
<p>Type</p>
|
||||
<div class="notetype-chooser">
|
||||
<NotetypeChooser />
|
||||
</div>
|
||||
<p>Deck</p>
|
||||
<div class="deck-chooser">
|
||||
<DeckChooser />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.top-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.notetype-chooser,
|
||||
.deck-chooser {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
Loading…
Reference in a new issue