Party sharing frontend (#455)
## Summary - Add visibility dropdown (Public/Private/Unlisted) to party edit sidebar - Add "Share with Crew" switch for crew members - Implement /crew/teams page showing parties shared with crew - Add API layer for party shares and crew shared parties ## Test plan - [ ] Open party settings, verify visibility dropdown appears - [ ] For users in a crew, verify "Share with Crew" switch appears - [ ] Toggle crew share on/off, verify it persists - [ ] Navigate to /crew/teams, verify shared parties load - [ ] Test infinite scroll pagination on crew teams page
This commit is contained in:
parent
a6d77a4463
commit
b5d30997b8
9 changed files with 373 additions and 8 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { BaseAdapter } from './base.adapter'
|
||||
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
||||
import type { RequestOptions } from './types'
|
||||
import type { RequestOptions, PaginatedResponse } from './types'
|
||||
import type { Party } from '$lib/types/api/party'
|
||||
import type {
|
||||
Crew,
|
||||
CrewMembership,
|
||||
|
|
@ -31,6 +32,23 @@ export class CrewAdapter extends BaseAdapter {
|
|||
return response.crew
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parties shared with the user's crew
|
||||
*/
|
||||
async getSharedParties(
|
||||
page = 1,
|
||||
perPage = 20,
|
||||
options?: RequestOptions
|
||||
): Promise<{ parties: Party[]; meta: { page: number; totalPages: number; count: number; perPage: number } }> {
|
||||
return this.request<{ parties: Party[]; meta: { page: number; totalPages: number; count: number; perPage: number } }>(
|
||||
'/crew/shared_parties',
|
||||
{
|
||||
...options,
|
||||
params: { page, per_page: perPage }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new crew (user becomes captain)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { BaseAdapter } from './base.adapter'
|
|||
import type { AdapterOptions, PaginatedResponse } from './types'
|
||||
import { DEFAULT_ADAPTER_CONFIG } from './config'
|
||||
import type { Party, GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party'
|
||||
import type { PartyShare } from '$lib/types/api/partyShare'
|
||||
|
||||
/**
|
||||
* Parameters for creating a new party
|
||||
|
|
@ -409,6 +410,28 @@ export class PartyAdapter extends BaseAdapter {
|
|||
this.clearCache(`/parties/${shortcode}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a party with the current user's crew
|
||||
* @param partyId - The party's UUID
|
||||
*/
|
||||
async shareWithCrew(partyId: string): Promise<PartyShare> {
|
||||
const response = await this.request<{ share: PartyShare }>(`/parties/${partyId}/shares`, {
|
||||
method: 'POST'
|
||||
})
|
||||
return response.share
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a party share
|
||||
* @param partyId - The party's UUID
|
||||
* @param shareId - The share's UUID to remove
|
||||
*/
|
||||
async removeShare(partyId: string, shareId: string): Promise<void> {
|
||||
await this.request(`/parties/${partyId}/shares/${shareId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cache for party-related data
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '$lib/api/adapters/party.adapter'
|
||||
import { partyKeys } from '$lib/api/queries/party.queries'
|
||||
import { userKeys } from '$lib/api/queries/user.queries'
|
||||
import { crewKeys } from '$lib/api/queries/crew.queries'
|
||||
import type { Party } from '$lib/types/api/party'
|
||||
|
||||
/**
|
||||
|
|
@ -312,3 +313,69 @@ export function useRegeneratePreview() {
|
|||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Share party with crew mutation
|
||||
*
|
||||
* Shares a party with the current user's crew.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { useSharePartyWithCrew } from '$lib/api/mutations/party.mutations'
|
||||
*
|
||||
* const shareParty = useSharePartyWithCrew()
|
||||
*
|
||||
* function handleShare(partyId: string, shortcode: string) {
|
||||
* shareParty.mutate({ partyId, shortcode })
|
||||
* }
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useSharePartyWithCrew() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return createMutation(() => ({
|
||||
mutationFn: ({ partyId }: { partyId: string; shortcode: string }) =>
|
||||
partyAdapter.shareWithCrew(partyId),
|
||||
onSuccess: (_share, { shortcode }) => {
|
||||
// Invalidate the party to refresh its shares
|
||||
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
|
||||
// Invalidate crew's shared parties list
|
||||
queryClient.invalidateQueries({ queryKey: crewKeys.sharedParties() })
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove party share mutation
|
||||
*
|
||||
* Removes a share from a party.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { useRemovePartyShare } from '$lib/api/mutations/party.mutations'
|
||||
*
|
||||
* const removeShare = useRemovePartyShare()
|
||||
*
|
||||
* function handleRemoveShare(partyId: string, shareId: string, shortcode: string) {
|
||||
* removeShare.mutate({ partyId, shareId, shortcode })
|
||||
* }
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useRemovePartyShare() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return createMutation(() => ({
|
||||
mutationFn: ({ partyId, shareId }: { partyId: string; shareId: string; shortcode: string }) =>
|
||||
partyAdapter.removeShare(partyId, shareId),
|
||||
onSuccess: (_data, { shortcode }) => {
|
||||
// Invalidate the party to refresh its shares
|
||||
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
|
||||
// Invalidate crew's shared parties list
|
||||
queryClient.invalidateQueries({ queryKey: crewKeys.sharedParties() })
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* @module api/queries/crew
|
||||
*/
|
||||
|
||||
import { queryOptions } from '@tanstack/svelte-query'
|
||||
import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query'
|
||||
import { crewAdapter } from '$lib/api/adapters/crew.adapter'
|
||||
import type { MemberFilter } from '$lib/types/api/crew'
|
||||
|
||||
|
|
@ -96,6 +96,30 @@ export const crewQueries = {
|
|||
queryFn: () => crewAdapter.getPendingPhantomClaims(),
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
gcTime: 1000 * 60 * 15 // 15 minutes
|
||||
}),
|
||||
|
||||
/**
|
||||
* Parties shared with the crew query options
|
||||
* Uses infinite query for pagination
|
||||
*/
|
||||
sharedParties: () =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: ['crew', 'shared_parties'] as const,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await crewAdapter.getSharedParties(pageParam)
|
||||
return {
|
||||
parties: response.parties,
|
||||
page: response.meta.page,
|
||||
totalPages: response.meta.totalPages,
|
||||
total: response.meta.count,
|
||||
perPage: response.meta.perPage
|
||||
}
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
gcTime: 1000 * 60 * 15 // 15 minutes
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +146,7 @@ export const crewKeys = {
|
|||
membersAll: () => [...crewKeys.all, 'members'] as const,
|
||||
members: (filter: MemberFilter) => [...crewKeys.all, 'members', filter] as const,
|
||||
crewInvitations: (crewId: string) => [...crewKeys.all, crewId, 'invitations'] as const,
|
||||
sharedParties: () => [...crewKeys.all, 'shared_parties'] as const,
|
||||
invitations: {
|
||||
all: ['invitations'] as const,
|
||||
pending: () => ['invitations', 'pending'] as const
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@
|
|||
useDeleteParty,
|
||||
useRemixParty,
|
||||
useFavoriteParty,
|
||||
useUnfavoriteParty
|
||||
useUnfavoriteParty,
|
||||
useSharePartyWithCrew,
|
||||
useRemovePartyShare
|
||||
} from '$lib/api/mutations/party.mutations'
|
||||
|
||||
// TanStack Query mutations - Job
|
||||
|
|
@ -158,6 +160,8 @@
|
|||
const remixPartyMutation = useRemixParty()
|
||||
const favoritePartyMutation = useFavoriteParty()
|
||||
const unfavoritePartyMutation = useUnfavoriteParty()
|
||||
const sharePartyWithCrewMutation = useSharePartyWithCrew()
|
||||
const removePartyShareMutation = useRemovePartyShare()
|
||||
|
||||
// TanStack Query mutations - Job
|
||||
const updateJobMutation = useUpdatePartyJob()
|
||||
|
|
@ -443,9 +447,14 @@
|
|||
function openSettingsPanel() {
|
||||
if (!canEdit()) return
|
||||
|
||||
// Check if party is currently shared with a crew
|
||||
const isSharedWithCrew = party.shares?.some((s) => s.shareableType === 'crew') ?? false
|
||||
|
||||
const initialValues: PartyEditValues = {
|
||||
name: party.name ?? '',
|
||||
description: party.description ?? null,
|
||||
visibility: party.visibility ?? 'public',
|
||||
sharedWithCrew: isSharedWithCrew,
|
||||
fullAuto: party.fullAuto ?? false,
|
||||
autoGuard: party.autoGuard ?? false,
|
||||
autoSummon: party.autoSummon ?? false,
|
||||
|
|
@ -463,9 +472,11 @@
|
|||
initialValues,
|
||||
element: userElement,
|
||||
onSave: async (values) => {
|
||||
// Update party details including visibility
|
||||
await updatePartyDetails({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
visibility: values.visibility,
|
||||
fullAuto: values.fullAuto,
|
||||
autoGuard: values.autoGuard,
|
||||
autoSummon: values.autoSummon,
|
||||
|
|
@ -477,6 +488,34 @@
|
|||
videoUrl: values.videoUrl,
|
||||
raidId: values.raidId
|
||||
})
|
||||
|
||||
// Handle crew share toggle
|
||||
const wasShared = party.shares?.some((s) => s.shareableType === 'crew') ?? false
|
||||
if (values.sharedWithCrew && !wasShared) {
|
||||
// Share with crew
|
||||
try {
|
||||
await sharePartyWithCrewMutation.mutateAsync({
|
||||
partyId: party.id,
|
||||
shortcode: party.shortcode
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error('Failed to share with crew:', err)
|
||||
}
|
||||
} else if (!values.sharedWithCrew && wasShared) {
|
||||
// Remove crew share
|
||||
const share = party.shares?.find((s) => s.shareableType === 'crew')
|
||||
if (share) {
|
||||
try {
|
||||
await removePartyShareMutation.mutateAsync({
|
||||
partyId: party.id,
|
||||
shareId: share.id,
|
||||
shortcode: party.shortcode
|
||||
})
|
||||
} catch (err: any) {
|
||||
console.error('Failed to remove share:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,16 +14,22 @@
|
|||
import MetricField from '$lib/components/party/edit/MetricField.svelte'
|
||||
import EditRaidPane from '$lib/components/sidebar/EditRaidPane.svelte'
|
||||
import EditDescriptionPane from '$lib/components/sidebar/EditDescriptionPane.svelte'
|
||||
import Select from '$lib/components/ui/Select.svelte'
|
||||
import Switch from '$lib/components/ui/switch/Switch.svelte'
|
||||
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||
import { usePaneStack } from '$lib/stores/paneStack.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import type { Raid } from '$lib/types/api/entities'
|
||||
import type { RaidFull } from '$lib/types/api/raid'
|
||||
import type { PartyVisibility } from '$lib/types/visibility'
|
||||
import Icon from '$lib/components/Icon.svelte'
|
||||
|
||||
export interface PartyEditValues {
|
||||
name: string
|
||||
description: string | null
|
||||
visibility: PartyVisibility
|
||||
sharedWithCrew: boolean
|
||||
fullAuto: boolean
|
||||
autoGuard: boolean
|
||||
autoSummon: boolean
|
||||
|
|
@ -55,6 +61,8 @@
|
|||
|
||||
// Local state - initialized from initialValues
|
||||
let name = $state(initialValues.name)
|
||||
let visibility = $state<PartyVisibility>(initialValues.visibility)
|
||||
let sharedWithCrew = $state(initialValues.sharedWithCrew)
|
||||
let fullAuto = $state(initialValues.fullAuto)
|
||||
let autoGuard = $state(initialValues.autoGuard)
|
||||
let autoSummon = $state(initialValues.autoSummon)
|
||||
|
|
@ -68,9 +76,18 @@
|
|||
let raidId = $state<string | null>(initialValues.raidId)
|
||||
let description = $state(initialValues.description)
|
||||
|
||||
// Visibility options for select
|
||||
const visibilityOptions: Array<{ value: PartyVisibility; label: string }> = [
|
||||
{ value: 'public', label: 'Public' },
|
||||
{ value: 'private', label: 'Private' },
|
||||
{ value: 'unlisted', label: 'Unlisted' }
|
||||
]
|
||||
|
||||
// Check if any values have changed
|
||||
const hasChanges = $derived(
|
||||
name !== initialValues.name ||
|
||||
visibility !== initialValues.visibility ||
|
||||
sharedWithCrew !== initialValues.sharedWithCrew ||
|
||||
fullAuto !== initialValues.fullAuto ||
|
||||
autoGuard !== initialValues.autoGuard ||
|
||||
autoSummon !== initialValues.autoSummon ||
|
||||
|
|
@ -89,6 +106,8 @@
|
|||
const values: PartyEditValues = {
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
sharedWithCrew,
|
||||
fullAuto,
|
||||
autoGuard,
|
||||
autoSummon,
|
||||
|
|
@ -264,6 +283,30 @@
|
|||
<YouTubeUrlInput label="Video" bind:value={videoUrl} contained />
|
||||
</div>
|
||||
|
||||
<DetailsSection title="Sharing">
|
||||
<DetailRow label="Visibility" noHover compact>
|
||||
{#snippet children()}
|
||||
<Select
|
||||
options={visibilityOptions}
|
||||
bind:value={visibility}
|
||||
size="small"
|
||||
contained
|
||||
/>
|
||||
{/snippet}
|
||||
</DetailRow>
|
||||
{#if crewStore.isInCrew}
|
||||
<DetailRow label="Share with Crew" noHover compact>
|
||||
{#snippet children()}
|
||||
<Switch
|
||||
bind:checked={sharedWithCrew}
|
||||
size="small"
|
||||
{element}
|
||||
/>
|
||||
{/snippet}
|
||||
</DetailRow>
|
||||
{/if}
|
||||
</DetailsSection>
|
||||
|
||||
<button type="button" class="description-button" onclick={openDescriptionPane}>
|
||||
<div class="description-header">
|
||||
<span class="description-label">Description</span>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
} from './entities'
|
||||
import type { GridArtifact, CollectionArtifact } from './artifact'
|
||||
import type { AugmentSkill, Befoulment } from './weaponStatModifier'
|
||||
import type { PartyShare } from './partyShare'
|
||||
|
||||
// Grid item types - these are the junction tables between Party and entities
|
||||
|
||||
|
|
@ -139,6 +140,8 @@ export interface Party {
|
|||
user?: User
|
||||
sourceParty?: Party
|
||||
remixes?: Party[]
|
||||
/** Shares for this party (only present for owner) */
|
||||
shares?: PartyShare[]
|
||||
|
||||
// Local client state
|
||||
localId?: string
|
||||
|
|
|
|||
10
src/lib/types/api/partyShare.ts
Normal file
10
src/lib/types/api/partyShare.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// PartyShare type for party sharing with crews/groups
|
||||
// Based on PartyShareBlueprint from Rails API
|
||||
|
||||
export interface PartyShare {
|
||||
id: string
|
||||
shareableType: string
|
||||
shareableId: string
|
||||
shareableName?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
|
@ -1,9 +1,16 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte'
|
||||
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
||||
import { crewStore } from '$lib/stores/crew.store.svelte'
|
||||
import { crewQueries } from '$lib/api/queries/crew.queries'
|
||||
import { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
|
||||
import CrewHeader from '$lib/components/crew/CrewHeader.svelte'
|
||||
import CrewTabs from '$lib/components/crew/CrewTabs.svelte'
|
||||
import ExploreGrid from '$lib/components/explore/ExploreGrid.svelte'
|
||||
import Icon from '$lib/components/Icon.svelte'
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -11,6 +18,30 @@
|
|||
}
|
||||
|
||||
let { data }: Props = $props()
|
||||
|
||||
let sentinelEl = $state<HTMLElement>()
|
||||
|
||||
// Query for shared parties
|
||||
const sharedPartiesQuery = createInfiniteQuery(() => ({
|
||||
...crewQueries.sharedParties(),
|
||||
enabled: crewStore.isInCrew
|
||||
}))
|
||||
|
||||
// State-gated infinite scroll
|
||||
const loader = useInfiniteLoader(
|
||||
() => sharedPartiesQuery,
|
||||
() => sentinelEl,
|
||||
{ rootMargin: '300px' }
|
||||
)
|
||||
|
||||
// Cleanup on destroy
|
||||
onDestroy(() => loader.destroy())
|
||||
|
||||
const items = $derived(
|
||||
sharedPartiesQuery.data?.pages.flatMap((page) => page.parties) ?? []
|
||||
)
|
||||
|
||||
const isEmpty = $derived(!sharedPartiesQuery.isLoading && items.length === 0)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -27,8 +58,53 @@
|
|||
|
||||
<CrewTabs userElement={data.currentUser?.element} />
|
||||
|
||||
<div class="empty-state">
|
||||
<p>Coming soon</p>
|
||||
<div class="content">
|
||||
{#if !crewStore.isInCrew}
|
||||
<div class="empty-state">
|
||||
<Icon name="users" size={32} />
|
||||
<p>You're not in a crew</p>
|
||||
</div>
|
||||
{:else if sharedPartiesQuery.isLoading}
|
||||
<div class="loading-state">
|
||||
<Icon name="loader-2" size={32} class="spin" />
|
||||
<p>Loading shared teams...</p>
|
||||
</div>
|
||||
{:else if sharedPartiesQuery.isError}
|
||||
<div class="error-state">
|
||||
<Icon name="alert-circle" size={32} />
|
||||
<p>Failed to load teams: {sharedPartiesQuery.error?.message || 'Unknown error'}</p>
|
||||
<Button size="small" onclick={() => sharedPartiesQuery.refetch()}>Retry</Button>
|
||||
</div>
|
||||
{:else if isEmpty}
|
||||
<div class="empty-state">
|
||||
<Icon name="share-2" size={32} />
|
||||
<p>No teams have been shared with your crew yet</p>
|
||||
<span class="hint">Crew members can share their teams from the team settings</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="teams-grid">
|
||||
<ExploreGrid {items} />
|
||||
|
||||
<div
|
||||
class="load-more-sentinel"
|
||||
bind:this={sentinelEl}
|
||||
class:hidden={!sharedPartiesQuery.hasNextPage}
|
||||
></div>
|
||||
|
||||
{#if sharedPartiesQuery.isFetchingNextPage}
|
||||
<div class="loading-more">
|
||||
<Icon name="loader-2" size={20} class="spin" />
|
||||
<span>Loading more...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !sharedPartiesQuery.hasNextPage && items.length > 0}
|
||||
<div class="end-message">
|
||||
<p>You've seen all shared teams</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -52,17 +128,78 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
.content {
|
||||
padding: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.teams-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: spacing.$unit-2x;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: spacing.$unit-4x;
|
||||
padding: spacing.$unit-6x spacing.$unit-4x;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
gap: spacing.$unit;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: typography.$font-regular;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: typography.$font-small;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: spacing.$unit;
|
||||
padding: spacing.$unit-2x;
|
||||
color: var(--text-secondary);
|
||||
font-size: typography.$font-small;
|
||||
}
|
||||
|
||||
.end-message {
|
||||
text-align: center;
|
||||
padding: spacing.$unit-2x;
|
||||
color: var(--text-tertiary);
|
||||
font-size: typography.$font-small;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-sentinel {
|
||||
height: 1px;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.spin) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue