feat: add suggestion UI components for batch import
- SuggestionBadge: sparkle icon with tooltip for accept/dismiss actions - SuggestionDetailItem: detail item wrapper with suggestion badge support - TabbedEntitySelector: entity image grid for batch selection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b28ba551de
commit
bba78d5781
3 changed files with 587 additions and 0 deletions
183
src/lib/components/ui/SuggestionBadge.svelte
Normal file
183
src/lib/components/ui/SuggestionBadge.svelte
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip as TooltipBase } from 'bits-ui'
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The suggested value to display */
|
||||||
|
suggestion: string | number | boolean | null | undefined
|
||||||
|
/** Label for the suggestion (e.g., field name) */
|
||||||
|
label?: string
|
||||||
|
/** Whether the suggestion has been dismissed */
|
||||||
|
dismissed?: boolean
|
||||||
|
/** Callback when user accepts the suggestion */
|
||||||
|
onAccept?: () => void
|
||||||
|
/** Callback when user dismisses the suggestion */
|
||||||
|
onDismiss?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { suggestion, label, dismissed = false, onAccept, onDismiss }: Props = $props()
|
||||||
|
|
||||||
|
// Format the suggestion for display
|
||||||
|
const displayValue = $derived(() => {
|
||||||
|
if (suggestion === null || suggestion === undefined) return 'None'
|
||||||
|
if (typeof suggestion === 'boolean') return suggestion ? 'Yes' : 'No'
|
||||||
|
return String(suggestion)
|
||||||
|
})
|
||||||
|
|
||||||
|
let isOpen = $state(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if suggestion !== undefined && suggestion !== null && !dismissed}
|
||||||
|
<TooltipBase.Root bind:open={isOpen} openDelay={0} closeDelay={100}>
|
||||||
|
<TooltipBase.Trigger asChild let:builder>
|
||||||
|
<button
|
||||||
|
{...builder}
|
||||||
|
type="button"
|
||||||
|
class="suggestion-badge"
|
||||||
|
aria-label="Wiki suggestion available"
|
||||||
|
>
|
||||||
|
<Icon name="sparkles" size={14} />
|
||||||
|
</button>
|
||||||
|
</TooltipBase.Trigger>
|
||||||
|
<TooltipBase.Content class="suggestion-tooltip" sideOffset={4}>
|
||||||
|
<div class="suggestion-content">
|
||||||
|
{#if label}
|
||||||
|
<span class="suggestion-label">{label}:</span>
|
||||||
|
{/if}
|
||||||
|
<span class="suggestion-value">{displayValue()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="suggestion-actions">
|
||||||
|
{#if onAccept}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action accept"
|
||||||
|
onclick={() => {
|
||||||
|
onAccept?.()
|
||||||
|
isOpen = false
|
||||||
|
}}
|
||||||
|
title="Accept suggestion"
|
||||||
|
>
|
||||||
|
<Icon name="check" size={14} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if onDismiss}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action dismiss"
|
||||||
|
onclick={() => {
|
||||||
|
onDismiss?.()
|
||||||
|
isOpen = false
|
||||||
|
}}
|
||||||
|
title="Dismiss suggestion"
|
||||||
|
>
|
||||||
|
<Icon name="x" size={14} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</TooltipBase.Content>
|
||||||
|
</TooltipBase.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/typography' as *;
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
|
||||||
|
.suggestion-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc($unit * 2.5);
|
||||||
|
height: calc($unit * 2.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, $wind-text-20 0%, $water-text-20 100%);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: $unit-half;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 8px rgba($wind-text-20, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.suggestion-tooltip) {
|
||||||
|
background: var(--tooltip-bg, #2a2a2a);
|
||||||
|
color: var(--tooltip-text, white);
|
||||||
|
padding: $unit;
|
||||||
|
border-radius: $item-corner;
|
||||||
|
font-size: $font-small;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-fourth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-label {
|
||||||
|
font-size: calc($font-small * 0.9);
|
||||||
|
color: $grey-50;
|
||||||
|
font-weight: $normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-value {
|
||||||
|
font-weight: $medium;
|
||||||
|
color: white;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-half;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: $unit-half;
|
||||||
|
border-top: 1px solid rgba(white, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc($unit * 3.5);
|
||||||
|
height: calc($unit * 3.5);
|
||||||
|
border-radius: $item-corner-small;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&.accept {
|
||||||
|
background: $wind-text-20;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $wind-text-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dismiss {
|
||||||
|
background: $grey-60;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
229
src/lib/components/ui/SuggestionDetailItem.svelte
Normal file
229
src/lib/components/ui/SuggestionDetailItem.svelte
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
import Input from './Input.svelte'
|
||||||
|
import Select from './Select.svelte'
|
||||||
|
import Checkbox from './checkbox/Checkbox.svelte'
|
||||||
|
import SuggestionBadge from './SuggestionBadge.svelte'
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
value: string | number
|
||||||
|
label: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
sublabel,
|
||||||
|
value = $bindable(),
|
||||||
|
children,
|
||||||
|
editable = false,
|
||||||
|
type = 'text',
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
element,
|
||||||
|
onchange,
|
||||||
|
width,
|
||||||
|
// Suggestion props
|
||||||
|
suggestion,
|
||||||
|
suggestionLabel,
|
||||||
|
dismissedSuggestion = false,
|
||||||
|
onAcceptSuggestion,
|
||||||
|
onDismissSuggestion
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
/** Secondary label displayed below the main label */
|
||||||
|
sublabel?: string
|
||||||
|
value?: string | number | boolean | null | undefined
|
||||||
|
children?: Snippet
|
||||||
|
editable?: boolean
|
||||||
|
type?: 'text' | 'number' | 'select' | 'checkbox'
|
||||||
|
options?: SelectOption[]
|
||||||
|
placeholder?: string
|
||||||
|
element?: 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
|
||||||
|
/** Callback for checkbox type when value changes */
|
||||||
|
onchange?: (checked: boolean) => void
|
||||||
|
/** Custom width for the input field (e.g., '320px') */
|
||||||
|
width?: string
|
||||||
|
// Suggestion props
|
||||||
|
/** The suggested value from wiki */
|
||||||
|
suggestion?: string | number | boolean | null | undefined
|
||||||
|
/** Label for the suggestion tooltip */
|
||||||
|
suggestionLabel?: string
|
||||||
|
/** Whether the suggestion has been dismissed */
|
||||||
|
dismissedSuggestion?: boolean
|
||||||
|
/** Callback when user accepts the suggestion */
|
||||||
|
onAcceptSuggestion?: () => void
|
||||||
|
/** Callback when user dismisses the suggestion */
|
||||||
|
onDismissSuggestion?: () => void
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
// For checkbox type, derive the checked state from value
|
||||||
|
const checkboxValue = $derived(type === 'checkbox' ? Boolean(value) : false)
|
||||||
|
|
||||||
|
// Handle checkbox change and call onchange if provided
|
||||||
|
function handleCheckboxChange(checked: boolean) {
|
||||||
|
value = checked as any
|
||||||
|
onchange?.(checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show suggestion badge when:
|
||||||
|
// 1. We have a suggestion
|
||||||
|
// 2. The suggestion hasn't been dismissed
|
||||||
|
// 3. The current value is different from the suggestion (or value is empty)
|
||||||
|
const showSuggestion = $derived(
|
||||||
|
suggestion !== undefined &&
|
||||||
|
suggestion !== null &&
|
||||||
|
!dismissedSuggestion &&
|
||||||
|
(value === undefined || value === null || value === '' || value !== suggestion)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Format suggestion for display in badge
|
||||||
|
const formattedSuggestion = $derived(() => {
|
||||||
|
if (type === 'select' && options && suggestion !== undefined && suggestion !== null) {
|
||||||
|
const opt = options.find((o) => o.value === suggestion)
|
||||||
|
return opt?.label ?? suggestion
|
||||||
|
}
|
||||||
|
return suggestion
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="detail-item" class:editable class:hasChildren={!!children}>
|
||||||
|
<div class="label-container">
|
||||||
|
<div class="label-row">
|
||||||
|
<span class="label">{label}</span>
|
||||||
|
{#if editable && showSuggestion}
|
||||||
|
<SuggestionBadge
|
||||||
|
suggestion={formattedSuggestion()}
|
||||||
|
label={suggestionLabel || label}
|
||||||
|
dismissed={dismissedSuggestion}
|
||||||
|
onAccept={onAcceptSuggestion}
|
||||||
|
onDismiss={onDismissSuggestion}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if sublabel}
|
||||||
|
<span class="sublabel">{sublabel}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if editable}
|
||||||
|
<div class="edit-value" style:--custom-width={width}>
|
||||||
|
{#if type === 'select' && options}
|
||||||
|
<Select
|
||||||
|
bind:value={value as string | number | undefined}
|
||||||
|
{options}
|
||||||
|
{placeholder}
|
||||||
|
size="medium"
|
||||||
|
contained
|
||||||
|
/>
|
||||||
|
{:else if type === 'checkbox'}
|
||||||
|
<Checkbox
|
||||||
|
checked={checkboxValue}
|
||||||
|
onCheckedChange={handleCheckboxChange}
|
||||||
|
contained
|
||||||
|
{element}
|
||||||
|
/>
|
||||||
|
{:else if type === 'number'}
|
||||||
|
<Input
|
||||||
|
bind:value
|
||||||
|
type="number"
|
||||||
|
variant="number"
|
||||||
|
contained={true}
|
||||||
|
{placeholder}
|
||||||
|
alignRight={true}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Input bind:value type="text" contained={true} {placeholder} alignRight={false} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if children}
|
||||||
|
<div class="value" class:edit-value={editable}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="value">{value || '—'}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/colors' as colors;
|
||||||
|
@use '$src/themes/layout' as layout;
|
||||||
|
@use '$src/themes/spacing' as spacing;
|
||||||
|
@use '$src/themes/typography' as typography;
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: spacing.$unit 0;
|
||||||
|
border-radius: layout.$item-corner;
|
||||||
|
font-size: typography.$font-regular;
|
||||||
|
min-height: calc(spacing.$unit * 5);
|
||||||
|
|
||||||
|
&:not(.editable) {
|
||||||
|
padding: spacing.$unit;
|
||||||
|
margin: 0 calc(spacing.$unit * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.editable):not(.hasChildren) {
|
||||||
|
background: colors.$grey-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.editable:focus-within,
|
||||||
|
&.hasChildren:focus-within {
|
||||||
|
background: var(--input-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.editable,
|
||||||
|
&.hasChildren {
|
||||||
|
background: var(--input-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: spacing.$unit-2x;
|
||||||
|
gap: spacing.$unit-fourth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: typography.$medium;
|
||||||
|
color: colors.$grey-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sublabel {
|
||||||
|
font-size: typography.$font-small;
|
||||||
|
color: colors.$grey-60;
|
||||||
|
font-weight: typography.$normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: colors.$grey-30;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-value {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
:global(.input),
|
||||||
|
:global(.select) {
|
||||||
|
width: var(--custom-width, 240px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.input.number) {
|
||||||
|
width: var(--custom-width, 120px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
175
src/lib/features/database/import/TabbedEntitySelector.svelte
Normal file
175
src/lib/features/database/import/TabbedEntitySelector.svelte
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Icon from '$lib/components/Icon.svelte'
|
||||||
|
|
||||||
|
export interface EntityTab {
|
||||||
|
wikiPage: string
|
||||||
|
granblueId?: string
|
||||||
|
status: 'loading' | 'success' | 'error'
|
||||||
|
imageUrl: string
|
||||||
|
error?: string
|
||||||
|
saved?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entities: EntityTab[]
|
||||||
|
selectedWikiPage: string | null
|
||||||
|
onSelect: (wikiPage: string) => void
|
||||||
|
entityType: 'character' | 'weapon' | 'summon'
|
||||||
|
}
|
||||||
|
|
||||||
|
let { entities, selectedWikiPage, onSelect, entityType }: Props = $props()
|
||||||
|
|
||||||
|
// Get placeholder image based on entity type
|
||||||
|
const placeholderImage = $derived(() => {
|
||||||
|
switch (entityType) {
|
||||||
|
case 'character':
|
||||||
|
return '/images/placeholders/placeholder-character-grid.png'
|
||||||
|
case 'weapon':
|
||||||
|
return '/images/placeholders/placeholder-weapon-grid.png'
|
||||||
|
case 'summon':
|
||||||
|
return '/images/placeholders/placeholder-summon-sub.png'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="entity-selector">
|
||||||
|
{#each entities as entity}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="entity-tab"
|
||||||
|
class:selected={entity.wikiPage === selectedWikiPage}
|
||||||
|
class:error={entity.status === 'error'}
|
||||||
|
class:loading={entity.status === 'loading'}
|
||||||
|
class:saved={entity.saved}
|
||||||
|
onclick={() => onSelect(entity.wikiPage)}
|
||||||
|
title={entity.error || entity.wikiPage}
|
||||||
|
disabled={entity.saved}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={entity.granblueId ? entity.imageUrl : placeholderImage()}
|
||||||
|
alt={entity.wikiPage}
|
||||||
|
class="entity-image"
|
||||||
|
class:placeholder={!entity.granblueId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if entity.status === 'loading'}
|
||||||
|
<span class="status-overlay loading">
|
||||||
|
<Icon name="loader-2" size={20} />
|
||||||
|
</span>
|
||||||
|
{:else if entity.status === 'error'}
|
||||||
|
<span class="status-overlay error">
|
||||||
|
<Icon name="alert-circle" size={20} />
|
||||||
|
</span>
|
||||||
|
{:else if entity.saved}
|
||||||
|
<span class="status-overlay saved">
|
||||||
|
<Icon name="check" size={20} />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '$src/themes/spacing' as *;
|
||||||
|
@use '$src/themes/colors' as *;
|
||||||
|
@use '$src/themes/layout' as *;
|
||||||
|
@use '$src/themes/effects' as *;
|
||||||
|
|
||||||
|
.entity-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $unit;
|
||||||
|
padding: $unit-2x;
|
||||||
|
background: $grey-95;
|
||||||
|
border-radius: $card-corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-tab {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: $item-corner;
|
||||||
|
background: $grey-90;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
border-color: $grey-60;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: $water-text-20;
|
||||||
|
box-shadow: 0 0 8px rgba($water-text-20, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
border-color: $error;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.saved {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
&.placeholder {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(black, 0.7);
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.saved {
|
||||||
|
color: $wind-text-20;
|
||||||
|
background: rgba($wind-text-20, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue