feat(05-08): SettingsSheet (master toggle D-09) + AppNav avatar promoted to button
- Create SettingsSheet.tsx: bottom sheet (role=dialog, z:301, Escape+backdrop-close) with FamilySync Notifications toggle (role=switch, aria-checked, 44px target) wired to usePushSubscription setEnabled + permission state and inline permission-denied hint (AlertCircle + 'How to enable') when denied - Promote PhoneNav avatar div to button with onOpenSettings onClick + aria-label - Add onOpenSettings prop to DesktopNav; add avatar button at sidebar bottom - Thread onOpenSettings through AppNavProps - Add @keyframes spin to tokens.css (fixes missing keyframe used by SettingsSheet + SyncStateToast)
This commit is contained in:
@@ -19,25 +19,42 @@ interface AppNavProps {
|
|||||||
members?: LegendMember[]
|
members?: LegendMember[]
|
||||||
currentUserColor?: string
|
currentUserColor?: string
|
||||||
currentUserName?: string
|
currentUserName?: string
|
||||||
|
/** Called when the user avatar is tapped — opens the Settings sheet. */
|
||||||
|
onOpenSettings?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppNav({ members = [], currentUserColor, currentUserName }: AppNavProps) {
|
export function AppNav({ members = [], currentUserColor, currentUserName, onOpenSettings }: AppNavProps) {
|
||||||
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <PhoneNav currentUserColor={currentUserColor} currentUserName={currentUserName} />
|
return (
|
||||||
|
<PhoneNav
|
||||||
|
currentUserColor={currentUserColor}
|
||||||
|
currentUserName={currentUserName}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DesktopNav members={members} />
|
return (
|
||||||
|
<DesktopNav
|
||||||
|
members={members}
|
||||||
|
currentUserColor={currentUserColor}
|
||||||
|
currentUserName={currentUserName}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Phone: 48px top bar — app name left, user avatar right */
|
/** Phone: 48px top bar — app name left, user avatar right */
|
||||||
function PhoneNav({
|
function PhoneNav({
|
||||||
currentUserColor,
|
currentUserColor,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
|
onOpenSettings,
|
||||||
}: {
|
}: {
|
||||||
currentUserColor?: string
|
currentUserColor?: string
|
||||||
currentUserName?: string
|
currentUserName?: string
|
||||||
|
onOpenSettings?: () => void
|
||||||
}) {
|
}) {
|
||||||
const displayName = currentUserName ?? 'User'
|
const displayName = currentUserName ?? 'User'
|
||||||
const color = currentUserColor ?? 'var(--color-member-0)'
|
const color = currentUserColor ?? 'var(--color-member-0)'
|
||||||
@@ -69,43 +86,50 @@ function PhoneNav({
|
|||||||
FamilySync
|
FamilySync
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* User color avatar — aria-label + title per reviewer note */}
|
{/* User avatar — promoted to button to open Settings sheet (D-09) */}
|
||||||
<div
|
<button
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
aria-label={`${displayName} — open settings`}
|
||||||
style={{
|
style={{
|
||||||
width: '32px',
|
background: 'none',
|
||||||
height: '32px',
|
border: 'none',
|
||||||
borderRadius: '50%',
|
cursor: onOpenSettings ? 'pointer' : 'default',
|
||||||
background: color,
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
cursor: 'default',
|
|
||||||
// Ensure 44px tap area with padding
|
|
||||||
minWidth: '44px',
|
|
||||||
minHeight: '44px',
|
|
||||||
padding: '6px',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
}}
|
}}
|
||||||
aria-label={displayName}
|
|
||||||
title={displayName}
|
|
||||||
role="img"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '32px',
|
||||||
height: '100%',
|
height: '32px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: color,
|
background: color,
|
||||||
}}
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend */
|
/** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend + avatar */
|
||||||
function DesktopNav({ members }: { members: LegendMember[] }) {
|
function DesktopNav({
|
||||||
|
members,
|
||||||
|
currentUserColor,
|
||||||
|
currentUserName,
|
||||||
|
onOpenSettings,
|
||||||
|
}: {
|
||||||
|
members: LegendMember[]
|
||||||
|
currentUserColor?: string
|
||||||
|
currentUserName?: string
|
||||||
|
onOpenSettings?: () => void
|
||||||
|
}) {
|
||||||
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
|
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -183,6 +207,50 @@ function DesktopNav({ members }: { members: LegendMember[] }) {
|
|||||||
Calendars
|
Calendars
|
||||||
</div>
|
</div>
|
||||||
<ColorLegend members={members} />
|
<ColorLegend members={members} />
|
||||||
|
|
||||||
|
{/* User avatar — at bottom of sidebar, opens Settings sheet (D-09) */}
|
||||||
|
<div style={{ marginTop: 'auto', paddingTop: 'var(--space-6)' }}>
|
||||||
|
<button
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
aria-label={`${currentUserName ?? 'User'} — open settings`}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: onOpenSettings ? 'pointer' : 'default',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3, 12px)',
|
||||||
|
padding: 'var(--space-2) var(--space-3, 12px)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: currentUserColor ?? 'var(--color-member-0)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentUserName ?? 'User'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* SettingsSheet — master notifications toggle (D-09).
|
||||||
|
*
|
||||||
|
* A bottom sheet opened by tapping the user avatar in AppNav.
|
||||||
|
* Contains a single master on/off toggle for all FamilySync push notifications.
|
||||||
|
*
|
||||||
|
* UI-SPEC §Surface 2:
|
||||||
|
* - Bottom sheet, role="dialog", aria-modal, zIndex 301 (backdrop 300)
|
||||||
|
* - Heading "Settings" + X close button
|
||||||
|
* - Section label "Notifications" (uppercase, muted)
|
||||||
|
* - Bell icon + toggle row
|
||||||
|
* - Permission-denied hint when Notification.permission === 'denied'
|
||||||
|
*
|
||||||
|
* Toggle behavior:
|
||||||
|
* on → off: DELETE subscription + localStorage.notificationsEnabled='0'
|
||||||
|
* off → on (permission granted): silently subscribe (no OS dialog)
|
||||||
|
* off → on (permission default): triggers tap-gated subscribe() (OS dialog)
|
||||||
|
* off → on (permission denied): no-op, shows permission-denied hint inline
|
||||||
|
*
|
||||||
|
* Accessibility: role="switch", aria-checked, 44px touch targets, Escape closes.
|
||||||
|
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { X, Bell, AlertCircle, Loader2 } from 'lucide-react'
|
||||||
|
import { usePushSubscription } from '../hooks/usePushSubscription.js'
|
||||||
|
|
||||||
|
interface SettingsSheetProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||||
|
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription()
|
||||||
|
const [isTogglingOn, setIsTogglingOn] = useState(false)
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
// Compute initial toggle on/off state per UI-SPEC toggle initial state rule:
|
||||||
|
// on when notificationsEnabled !== '0' AND permission === 'granted' AND isSubscribed
|
||||||
|
const isOn = permission === 'granted' && isSubscribed
|
||||||
|
|
||||||
|
// Escape key listener (CreateListSheet pattern)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
// Focus close button on open (a11y)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && closeButtonRef.current) {
|
||||||
|
closeButtonRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
if (permission === 'denied') return // no-op — show hint below
|
||||||
|
|
||||||
|
if (isOn) {
|
||||||
|
// on → off: unsubscribe
|
||||||
|
await setEnabled(false)
|
||||||
|
} else if (permission === 'granted') {
|
||||||
|
// off → on, permission already granted: silent subscribe
|
||||||
|
setIsTogglingOn(true)
|
||||||
|
try {
|
||||||
|
await setEnabled(true)
|
||||||
|
} finally {
|
||||||
|
setIsTogglingOn(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// off → on, permission 'default': needs tap-gated subscribe with OS dialog
|
||||||
|
// The toggle click IS the tap gesture — call subscribe() directly here.
|
||||||
|
setIsTogglingOn(true)
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker?.ready
|
||||||
|
if (registration) {
|
||||||
|
await subscribe(registration)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Permission denied by OS or error — permission state will update reactively
|
||||||
|
} finally {
|
||||||
|
setIsTogglingOn(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled = permission === 'denied'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
||||||
|
zIndex: 300,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sheet */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Settings"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'var(--color-surface-raised, #ffffff)',
|
||||||
|
borderRadius: '12px 12px 0 0',
|
||||||
|
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||||
|
padding: 'var(--space-6, 24px)',
|
||||||
|
zIndex: 301,
|
||||||
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||||
|
maxWidth: '480px',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Heading row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 'var(--space-6, 24px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--text-heading-size, 18px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||||
|
color: 'var(--color-text-primary, #111318)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
ref={closeButtonRef}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close settings"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={20} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section label */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-muted, #9CA3AF)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
marginBottom: 'var(--space-2, 8px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Notifications
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3, 12px)',
|
||||||
|
padding: 'var(--space-2, 8px) 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bell
|
||||||
|
size={20}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Label column */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'var(--color-text-primary, #111318)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
FamilySync Notifications
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reminders, event changes, list updates
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle switch or spinner */}
|
||||||
|
{isTogglingOn ? (
|
||||||
|
<Loader2
|
||||||
|
size={20}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isOn}
|
||||||
|
aria-label={isOn ? 'FamilySync Notifications, on' : 'FamilySync Notifications, off'}
|
||||||
|
onClick={() => { void handleToggle() }}
|
||||||
|
disabled={isDisabled}
|
||||||
|
style={{
|
||||||
|
// 44px touch target
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: isDisabled ? 'default' : 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: isDisabled ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Toggle track */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '44px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: isOn
|
||||||
|
? 'var(--color-member-0, #4A90D9)'
|
||||||
|
: 'var(--color-border, #E2E4E9)',
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'background 0.15s ease',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Toggle thumb */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '2px',
|
||||||
|
left: isOn ? '22px' : '2px',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#ffffff',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
transition: 'left 0.15s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permission-denied hint — only when OS permission === 'denied' */}
|
||||||
|
{permission === 'denied' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 'var(--space-2, 8px)',
|
||||||
|
marginTop: 'var(--space-2, 8px)',
|
||||||
|
padding: 'var(--space-3, 12px)',
|
||||||
|
background: 'var(--color-surface-dim, #F7F7F8)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle
|
||||||
|
size={16}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
color: 'var(--color-destructive, #DC2626)',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: '1px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Notifications are blocked in your browser settings.{' '}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
color: 'var(--color-focus-ring, #4A90D9)',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
How to enable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -136,3 +136,12 @@
|
|||||||
background-position: 200% 0;
|
background-position: 200% 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user