mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2026-04-06 01:25:31 +00:00
refactor(package/ui): os-spinner (#9245)
This commit is contained in:
parent
c3a65a410e
commit
d335e664bc
@ -81,10 +81,10 @@ Phase 0: ██████████ 100% (6/6 Aufgaben) ✅
|
|||||||
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
|
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
|
||||||
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
|
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
|
||||||
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett
|
Phase 3: ██████████ 100% (24/24 Aufgaben) ✅ - Webapp-Integration komplett
|
||||||
Phase 4: ██░░░░░░░░ 24% (4/17 Aufgaben) - OsButton ✅, OsIcon ✅, System-Icons ✅, BaseIcon→OsIcon Migration ✅
|
Phase 4: ████░░░░░░ 35% (6/17 Aufgaben) - OsButton ✅, OsIcon ✅, System-Icons ✅, BaseIcon→OsIcon Migration ✅, OsSpinner ✅, Spinner Webapp-Migration ✅
|
||||||
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
|
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
|
||||||
───────────────────────────────────────
|
───────────────────────────────────────
|
||||||
Gesamt: ████████░░ 77% (66/86 Aufgaben)
|
Gesamt: ████████░░ 79% (68/86 Aufgaben)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Katalogisierung (Details in KATALOG.md)
|
### Katalogisierung (Details in KATALOG.md)
|
||||||
@ -133,6 +133,25 @@ System-Icons:
|
|||||||
Ocelot-Icons (separates Entry-Point):
|
Ocelot-Icons (separates Entry-Point):
|
||||||
└─ 82 Icons (Feature-Icons + Kategorie-Icons aus Webapp migriert)
|
└─ 82 Icons (Feature-Icons + Kategorie-Icons aus Webapp migriert)
|
||||||
|
|
||||||
|
OsSpinner:
|
||||||
|
├─ size: ✅ xs, sm, md, lg, xl, 2xl (em-basiert)
|
||||||
|
├─ color: ✅ currentColor (erbt von Parent)
|
||||||
|
├─ a11y: ✅ role="status", aria-label="Loading" (customizable)
|
||||||
|
├─ decorative: ✅ aria-hidden="true" suppresses role/aria-label
|
||||||
|
├─ os-button: ✅ OsButton nutzt OsSpinner als Komponente (decorative)
|
||||||
|
├─ vue-compat: ✅ h() Render-Function mit isVue2
|
||||||
|
└─ webapp: ✅ 4 Spinner migriert (DsSpinner + LoadingSpinner → OsSpinner)
|
||||||
|
|
||||||
|
DsSpinner/LoadingSpinner → OsSpinner Webapp-Migration: ✅
|
||||||
|
├─ ImageUploader.vue: LoadingSpinner → OsSpinner (size="lg")
|
||||||
|
├─ pages/profile: ds-spinner → os-spinner (size="lg")
|
||||||
|
├─ pages/groups: ds-spinner → os-spinner (size="lg")
|
||||||
|
├─ pages/admin: ds-spinner → os-spinner (size="xl") + ApolloQuery→apollo Option
|
||||||
|
├─ LoadingSpinner Komponente gelöscht
|
||||||
|
├─ ds-space centered → div+padding (Bugfix in 3 Seiten)
|
||||||
|
├─ Admin: ApolloQuery→$apollo.loading (Spinner war wg. SSR-Prefetch unsichtbar)
|
||||||
|
└─ infinite-loading: OsSpinner im spinner-Slot (index, profile, groups)
|
||||||
|
|
||||||
BaseIcon → OsIcon Webapp-Migration: ✅
|
BaseIcon → OsIcon Webapp-Migration: ✅
|
||||||
├─ 131 <base-icon> in 70+ Dateien → <os-icon :icon="...">
|
├─ 131 <base-icon> in 70+ Dateien → <os-icon :icon="...">
|
||||||
├─ 82 SVGs in ocelot/icons/svgs/ (inkl. 17 Kategorie-Icons)
|
├─ 82 SVGs in ocelot/icons/svgs/ (inkl. 17 Kategorie-Icons)
|
||||||
@ -147,11 +166,35 @@ BaseIcon → OsIcon Webapp-Migration: ✅
|
|||||||
|
|
||||||
## Aktueller Stand
|
## Aktueller Stand
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 2026-02-15 (Session 22)
|
**Letzte Aktualisierung:** 2026-02-18 (Session 24)
|
||||||
|
|
||||||
**Aktuelle Phase:** Phase 4 - OsIcon ✅, BaseIcon → OsIcon Webapp-Migration ✅
|
**Aktuelle Phase:** Phase 4 - OsIcon ✅, BaseIcon → OsIcon Migration ✅, OsSpinner ✅, Spinner Webapp-Migration ✅
|
||||||
|
|
||||||
**Zuletzt abgeschlossen (Session 22 - BaseIcon → OsIcon Webapp-Migration):**
|
**Zuletzt abgeschlossen (Session 24 - OsSpinner Webapp-Migration + Refactoring):**
|
||||||
|
- [x] OsButton refactored: nutzt `h(OsSpinner, { 'aria-hidden': 'true' })` statt Inline-SVG
|
||||||
|
- [x] OsSpinner: Decorative-Modus (`aria-hidden="true"` unterdrückt role/aria-label)
|
||||||
|
- [x] `ButtonSize` Type exportiert (sm/md/lg/xl), `types.d.ts` Kommentar aktualisiert
|
||||||
|
- [x] `createSpinnerSvg.ts` wieder in OsSpinner.vue inlined (nur noch 1 Nutzer)
|
||||||
|
- [x] Webapp-Migration: 4 Spinner ersetzt (ImageUploader, profile, groups, admin)
|
||||||
|
- [x] LoadingSpinner Komponente gelöscht (ersetzt durch OsSpinner)
|
||||||
|
- [x] `<ds-space centered>` → `<div style="...">` Bugfix in 3 Seiten
|
||||||
|
- [x] Admin-Seite: `<ApolloQuery>` → `apollo`-Option + `$apollo.loading` (Spinner war wg. SSR-Prefetch unsichtbar)
|
||||||
|
- [x] `filterStatistics()` mutiert nicht mehr Originalobjekt (Destructuring statt `delete`)
|
||||||
|
- [x] `infinite-loading` Spinner-Slot: OsSpinner in allen 3 Nutzungen (index, profile, groups)
|
||||||
|
- [x] Einheitliches Spinner-Design: vue-infinite-loading Default-Spinner → OsSpinner
|
||||||
|
|
||||||
|
**Zuvor abgeschlossen (Session 23 - OsSpinner Komponente):**
|
||||||
|
- [x] OsSpinner Komponente implementiert (size prop, currentColor, role="status", aria-label)
|
||||||
|
- [x] Vue 2/3 kompatibel via `h()` Render-Function mit `isVue2`
|
||||||
|
- [x] Storybook Stories: Playground, AllSizes, InheritColor, InlineWithText
|
||||||
|
- [x] Unit Tests: 23 Tests (rendering, size, accessibility, decorative mode, css, keyboard)
|
||||||
|
- [x] Visual Tests: 4 Tests (all-sizes, inherit-color, inline-text, keyboard a11y)
|
||||||
|
- [x] Accessibility: `role="status"`, `aria-label="Loading"` (customizable), axe-core checks
|
||||||
|
- [x] 100% Test-Coverage (Statements, Branches, Functions, Lines)
|
||||||
|
- [x] Completeness Check bestanden
|
||||||
|
- [x] OsButton Visual Tests: 19/19 bestanden (kein Regression durch Refactoring)
|
||||||
|
|
||||||
|
**Zuvor abgeschlossen (Session 22 - BaseIcon → OsIcon Webapp-Migration):**
|
||||||
- [x] 131 `<base-icon>` Nutzungen in 70+ Dateien → `<os-icon :icon="icons.xxx">` migriert
|
- [x] 131 `<base-icon>` Nutzungen in 70+ Dateien → `<os-icon :icon="icons.xxx">` migriert
|
||||||
- [x] 82 Ocelot-Icons in `packages/ui/src/ocelot/icons/svgs/` (von 1 auf 82)
|
- [x] 82 Ocelot-Icons in `packages/ui/src/ocelot/icons/svgs/` (von 1 auf 82)
|
||||||
- [x] 17 Kategorie-Icons aus Webapp kopiert (networking, energy, psyche, movement, finance, child, mobility, shopping-cart, peace, politics, nature, science, health, media, spirituality, culture, miscellaneous)
|
- [x] 17 Kategorie-Icons aus Webapp kopiert (networking, energy, psyche, movement, finance, child, mobility, shopping-cart, peace, politics, nature, science, health, media, spirituality, culture, miscellaneous)
|
||||||
@ -219,7 +262,7 @@ BaseIcon → OsIcon Webapp-Migration: ✅
|
|||||||
- [x] Session 11: Wasserfarben-Farbschema, Stories konsolidiert, Keyboard A11y
|
- [x] Session 11: Wasserfarben-Farbschema, Stories konsolidiert, Keyboard A11y
|
||||||
|
|
||||||
**Nächste Schritte:**
|
**Nächste Schritte:**
|
||||||
- [ ] OsSpinner Komponente (vereint DsSpinner + LoadingSpinner)
|
- [x] OsSpinner Webapp-Migration (DsSpinner + LoadingSpinner → OsSpinner) ✅
|
||||||
- [ ] OsCard Komponente (vereint DsCard + BaseCard)
|
- [ ] OsCard Komponente (vereint DsCard + BaseCard)
|
||||||
- [ ] Weitere Tier 1 Komponenten
|
- [ ] Weitere Tier 1 Komponenten
|
||||||
- [ ] Browser-Fehler untersuchen: `TypeError: Cannot read properties of undefined (reading 'heartO')` (ocelotIcons undefined im Browser trotz korrekter Webpack-Aliase)
|
- [ ] Browser-Fehler untersuchen: `TypeError: Cannot read properties of undefined (reading 'heartO')` (ocelotIcons undefined im Browser trotz korrekter Webpack-Aliase)
|
||||||
@ -473,7 +516,8 @@ Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Activ
|
|||||||
|
|
||||||
**Tier 1: Kern-Komponenten**
|
**Tier 1: Kern-Komponenten**
|
||||||
- [x] OsIcon (vereint DsIcon + BaseIcon) ✅ System-Icons + vite-svg-icon Plugin
|
- [x] OsIcon (vereint DsIcon + BaseIcon) ✅ System-Icons + vite-svg-icon Plugin
|
||||||
- [ ] OsSpinner (vereint DsSpinner + LoadingSpinner)
|
- [x] OsSpinner (vereint DsSpinner + LoadingSpinner) ✅ OsButton nutzt OsSpinner als Komponente
|
||||||
|
- [x] OsSpinner Webapp-Migration ✅ 4 Spinner migriert, LoadingSpinner gelöscht, Admin ApolloQuery→$apollo.loading
|
||||||
- [x] OsButton (vereint DsButton + BaseButton) ✅ Entwickelt in Phase 2
|
- [x] OsButton (vereint DsButton + BaseButton) ✅ Entwickelt in Phase 2
|
||||||
- [ ] OsCard (vereint DsCard + BaseCard)
|
- [ ] OsCard (vereint DsCard + BaseCard)
|
||||||
|
|
||||||
@ -1577,6 +1621,14 @@ Bei der Migration werden:
|
|||||||
| 2026-02-15 | **8 Snapshots gelöscht** | Stale Snapshot-Dateien entfernt nach BaseIcon → OsIcon Migration |
|
| 2026-02-15 | **8 Snapshots gelöscht** | Stale Snapshot-Dateien entfernt nach BaseIcon → OsIcon Migration |
|
||||||
| 2026-02-15 | **CSS Migration** | `.base-icon` → `.os-icon` in main.scss und Category/index.vue |
|
| 2026-02-15 | **CSS Migration** | `.base-icon` → `.os-icon` in main.scss und Category/index.vue |
|
||||||
| 2026-02-15 | **Jest Mock ocelot** | `test/__mocks__/@ocelot-social/ui/ocelot.js` für ocelotIcons in Jest-Umgebung |
|
| 2026-02-15 | **Jest Mock ocelot** | `test/__mocks__/@ocelot-social/ui/ocelot.js` für ocelotIcons in Jest-Umgebung |
|
||||||
|
| 2026-02-18 | **OsSpinner Komponente** | Neue Komponente: size (xs-2xl, em-basiert), currentColor, role="status", aria-label; Vue 2/3 via h() + isVue2 |
|
||||||
|
| 2026-02-18 | **OsSpinner Decorative** | `aria-hidden="true"` unterdrückt role/aria-label; OsButton nutzt OsSpinner als Komponente (decorative) |
|
||||||
|
| 2026-02-18 | **ButtonSize Type** | `ButtonSize` (sm/md/lg/xl) exportiert aus button.variants.ts; types.d.ts Kommentar: Size ist Vokabular, nicht Pflicht |
|
||||||
|
| 2026-02-18 | **OsSpinner Webapp-Migration** | 4 Stellen migriert: ImageUploader, profile, groups, admin; LoadingSpinner gelöscht |
|
||||||
|
| 2026-02-18 | **ds-space centered Bugfix** | `<ds-space centered>` → `<div style="text-align:center;padding:48px 0">` in 3 Seiten (Styleguide-Bug) |
|
||||||
|
| 2026-02-18 | **Admin Spinner Fix** | `<ApolloQuery>` → `apollo`-Option + `$apollo.loading`; SSR-Prefetch verhinderte Loading-State im Client |
|
||||||
|
| 2026-02-18 | **filterStatistics Fix** | `delete data.__typename` → Destructuring `{ __typename, ...rest }` (keine Mutation des Originalobjekts) |
|
||||||
|
| 2026-02-18 | **infinite-loading Spinner-Slot** | OsSpinner im `spinner`-Slot von vue-infinite-loading in 3 Seiten (index, profile, groups); einheitliches Spinner-Design |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, getCurrentInstance, h, isVue2 } from 'vue-demi'
|
import { computed, defineComponent, getCurrentInstance, h, isVue2 } from 'vue-demi'
|
||||||
|
|
||||||
|
import OsSpinner from '#src/components/OsSpinner/OsSpinner.vue'
|
||||||
import { cn } from '#src/utils'
|
import { cn } from '#src/utils'
|
||||||
|
|
||||||
import { buttonVariants } from './button.variants'
|
import { buttonVariants } from './button.variants'
|
||||||
|
|
||||||
import type { ButtonVariants } from './button.variants'
|
import type { ButtonSize, ButtonVariants } from './button.variants'
|
||||||
import type { Component, PropType } from 'vue-demi'
|
import type { Component, PropType } from 'vue-demi'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,9 +25,7 @@
|
|||||||
* @slot suffix - Optional trailing content (rendered right of text). Icons, badges, chevrons etc.
|
* @slot suffix - Optional trailing content (rendered right of text). Icons, badges, chevrons etc.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Size = NonNullable<ButtonVariants['size']>
|
const CIRCLE_WIDTHS: Record<ButtonSize, string> = {
|
||||||
|
|
||||||
const CIRCLE_WIDTHS: Record<Size, string> = {
|
|
||||||
sm: 'w-[26px]',
|
sm: 'w-[26px]',
|
||||||
md: 'w-[36px]',
|
md: 'w-[36px]',
|
||||||
lg: 'w-12',
|
lg: 'w-12',
|
||||||
@ -38,42 +37,14 @@
|
|||||||
const ICON_CLASS = `os-button__icon ${SLOT_BASE}`
|
const ICON_CLASS = `os-button__icon ${SLOT_BASE}`
|
||||||
const SUFFIX_CLASS = `os-button__suffix ${SLOT_BASE}`
|
const SUFFIX_CLASS = `os-button__suffix ${SLOT_BASE}`
|
||||||
|
|
||||||
const SPINNER_PX: Record<Size, number> = { sm: 24, md: 32, lg: 40, xl: 46 }
|
const SPINNER_PX: Record<ButtonSize, number> = { sm: 24, md: 32, lg: 40, xl: 46 }
|
||||||
|
|
||||||
const SVG_ATTRS = {
|
|
||||||
viewBox: '0 0 50 50',
|
|
||||||
xmlns: 'http://www.w3.org/2000/svg',
|
|
||||||
'aria-hidden': 'true',
|
|
||||||
}
|
|
||||||
|
|
||||||
const CIRCLE_ATTRS = {
|
|
||||||
cx: '25',
|
|
||||||
cy: '25',
|
|
||||||
r: '20',
|
|
||||||
fill: 'none',
|
|
||||||
stroke: 'currentColor',
|
|
||||||
'stroke-width': '4',
|
|
||||||
'stroke-linecap': 'round',
|
|
||||||
}
|
|
||||||
|
|
||||||
const CIRCLE_STYLE =
|
|
||||||
'transform-origin:25px 25px;animation:os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite'
|
|
||||||
|
|
||||||
function vueAttrs(attrs: Record<string, string>, style?: string) {
|
|
||||||
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
|
|
||||||
return isVue2 ? { attrs, ...(style && { style }) } : { ...attrs, ...(style && { style }) }
|
|
||||||
/* v8 ignore stop */
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSpinner(px: number, center: string) {
|
function createSpinner(px: number, center: string) {
|
||||||
const svg = h('svg', vueAttrs(SVG_ATTRS, 'width:100%;height:100%;overflow:hidden'), [
|
return h(OsSpinner, {
|
||||||
h('circle', vueAttrs(CIRCLE_ATTRS, CIRCLE_STYLE)),
|
class: `os-button__spinner absolute ${center}`,
|
||||||
])
|
style: `width:${px}px;height:${px}px`,
|
||||||
return h(
|
'aria-hidden': 'true',
|
||||||
'span',
|
})
|
||||||
{ class: `os-button__spinner absolute ${center}`, style: `width:${px}px;height:${px}px` },
|
|
||||||
[svg],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@ -148,7 +119,7 @@
|
|||||||
return typeof children !== 'string' || children.trim().length > 0
|
return typeof children !== 'string' || children.trim().length > 0
|
||||||
}) ?? false
|
}) ?? false
|
||||||
|
|
||||||
const size = props.size as Size
|
const size = props.size as ButtonSize
|
||||||
const spinnerPx = SPINNER_PX[size] // eslint-disable-line security/detect-object-injection
|
const spinnerPx = SPINNER_PX[size] // eslint-disable-line security/detect-object-injection
|
||||||
const isSmall = props.circle || size === 'sm'
|
const isSmall = props.circle || size === 'sm'
|
||||||
const isLoading = props.loading
|
const isLoading = props.loading
|
||||||
|
|||||||
@ -269,3 +269,6 @@ export const buttonVariants = cva(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
|
|
||||||
|
/** Button-specific size subset: sm | md | lg | xl */
|
||||||
|
export type ButtonSize = NonNullable<ButtonVariants['size']>
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export { default as OsButton } from './OsButton.vue'
|
export { default as OsButton } from './OsButton.vue'
|
||||||
export { buttonVariants, type ButtonVariants } from './button.variants'
|
export { buttonVariants, type ButtonSize, type ButtonVariants } from './button.variants'
|
||||||
|
|||||||
149
packages/ui/src/components/OsSpinner/OsSpinner.spec.ts
Normal file
149
packages/ui/src/components/OsSpinner/OsSpinner.spec.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import OsSpinner from './OsSpinner.vue'
|
||||||
|
import { SPINNER_SIZES } from './spinner.variants'
|
||||||
|
|
||||||
|
import type { Size } from '#src/types'
|
||||||
|
|
||||||
|
describe('osSpinner', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders a spinner element', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.find('.os-spinner').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders an SVG circle', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.find('svg').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('circle').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders as span element', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect((wrapper.element as HTMLElement).tagName).toBe('SPAN')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('svg has correct viewBox', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.find('svg').attributes('viewBox')).toBe('0 0 50 50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('circle uses currentColor for stroke', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.find('circle').attributes('stroke')).toBe('currentColor')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('circle has animation style', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
const style = wrapper.find('circle').attributes('style') ?? ''
|
||||||
|
|
||||||
|
expect(style).toContain('os-spinner-rotate')
|
||||||
|
expect(style).toContain('os-spinner-dash')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('size prop', () => {
|
||||||
|
const sizes = Object.entries(SPINNER_SIZES)
|
||||||
|
|
||||||
|
it.each(sizes)('applies %s size classes', (size, expectedClasses) => {
|
||||||
|
const wrapper = mount(OsSpinner, {
|
||||||
|
props: { size: size as Size },
|
||||||
|
})
|
||||||
|
const classes = wrapper.classes()
|
||||||
|
|
||||||
|
for (const cls of expectedClasses.split(' ')) {
|
||||||
|
expect(classes).toContain(cls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to md size', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.classes()).toContain('h-[1.5em]')
|
||||||
|
expect(wrapper.classes()).toContain('w-[1.5em]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('has role="status"', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.attributes('role')).toBe('status')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has default aria-label "Loading"', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.attributes('aria-label')).toBe('Loading')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows custom aria-label', () => {
|
||||||
|
const wrapper = mount(OsSpinner, {
|
||||||
|
attrs: { 'aria-label': 'Saving changes' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.attributes('aria-label')).toBe('Saving changes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is decorative when aria-hidden="true"', () => {
|
||||||
|
const wrapper = mount(OsSpinner, {
|
||||||
|
attrs: { 'aria-hidden': 'true' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.attributes('aria-hidden')).toBe('true')
|
||||||
|
expect(wrapper.attributes('role')).toBeUndefined()
|
||||||
|
expect(wrapper.attributes('aria-label')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is semantic by default (no aria-hidden)', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.attributes('aria-hidden')).toBeUndefined()
|
||||||
|
expect(wrapper.attributes('role')).toBe('status')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('css', () => {
|
||||||
|
it('has inline-flex display', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.classes()).toContain('inline-flex')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has shrink-0 class', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.classes()).toContain('shrink-0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges custom classes', () => {
|
||||||
|
const wrapper = mount(OsSpinner, {
|
||||||
|
attrs: { class: 'text-red-500' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.classes()).toContain('text-red-500')
|
||||||
|
expect(wrapper.classes()).toContain('os-spinner')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('keyboard accessibility', () => {
|
||||||
|
it('is not focusable (non-interactive element)', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.attributes('tabindex')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no interactive role', () => {
|
||||||
|
const wrapper = mount(OsSpinner)
|
||||||
|
|
||||||
|
expect(wrapper.attributes('role')).not.toBe('button')
|
||||||
|
expect(wrapper.attributes('role')).not.toBe('link')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
113
packages/ui/src/components/OsSpinner/OsSpinner.stories.ts
Normal file
113
packages/ui/src/components/OsSpinner/OsSpinner.stories.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import OsSpinner from './OsSpinner.vue'
|
||||||
|
|
||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
const meta: Meta<typeof OsSpinner> = {
|
||||||
|
title: 'Components/OsSpinner',
|
||||||
|
component: OsSpinner,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof OsSpinner>
|
||||||
|
|
||||||
|
interface PlaygroundArgs {
|
||||||
|
size: string
|
||||||
|
ariaLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Playground: StoryObj<PlaygroundArgs> = {
|
||||||
|
argTypes: {
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['xs', 'sm', 'md', 'lg', 'xl', '2xl'],
|
||||||
|
},
|
||||||
|
ariaLabel: {
|
||||||
|
control: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
size: 'md',
|
||||||
|
ariaLabel: 'Loading',
|
||||||
|
},
|
||||||
|
render: (args) => ({
|
||||||
|
components: { OsSpinner },
|
||||||
|
setup() {
|
||||||
|
const spinnerProps = computed(() => ({
|
||||||
|
size: args.size,
|
||||||
|
}))
|
||||||
|
const ariaLabel = computed(() => args.ariaLabel || undefined)
|
||||||
|
return { spinnerProps, ariaLabel }
|
||||||
|
},
|
||||||
|
template: `<OsSpinner v-bind="spinnerProps" :aria-label="ariaLabel" />`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllSizes: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { OsSpinner },
|
||||||
|
template: `
|
||||||
|
<div data-testid="all-sizes" class="flex items-end gap-6">
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<OsSpinner size="xs" />
|
||||||
|
<span class="text-xs text-gray-500">xs</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<OsSpinner size="sm" />
|
||||||
|
<span class="text-xs text-gray-500">sm</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<OsSpinner size="md" />
|
||||||
|
<span class="text-xs text-gray-500">md</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<OsSpinner size="lg" />
|
||||||
|
<span class="text-xs text-gray-500">lg</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<OsSpinner size="xl" />
|
||||||
|
<span class="text-xs text-gray-500">xl</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<OsSpinner size="2xl" />
|
||||||
|
<span class="text-xs text-gray-500">2xl</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InheritColor: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { OsSpinner },
|
||||||
|
template: `
|
||||||
|
<div data-testid="inherit-color" class="flex items-center gap-6 text-lg">
|
||||||
|
<span class="text-red-500"><OsSpinner size="lg" aria-label="Error loading" /></span>
|
||||||
|
<span class="text-green-500"><OsSpinner size="lg" aria-label="Success loading" /></span>
|
||||||
|
<span class="text-blue-500"><OsSpinner size="lg" aria-label="Info loading" /></span>
|
||||||
|
<span class="text-gray-400"><OsSpinner size="lg" aria-label="Default loading" /></span>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InlineWithText: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { OsSpinner },
|
||||||
|
template: `
|
||||||
|
<div data-testid="inline-text" class="flex flex-col gap-4">
|
||||||
|
<p class="text-sm flex items-center gap-2">
|
||||||
|
<OsSpinner size="sm" /> Loading results...
|
||||||
|
</p>
|
||||||
|
<p class="text-base flex items-center gap-2">
|
||||||
|
<OsSpinner size="md" /> Processing your request...
|
||||||
|
</p>
|
||||||
|
<p class="text-lg flex items-center gap-2">
|
||||||
|
<OsSpinner size="lg" /> Please wait...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
101
packages/ui/src/components/OsSpinner/OsSpinner.visual.spec.ts
Normal file
101
packages/ui/src/components/OsSpinner/OsSpinner.visual.spec.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { AxeBuilder } from '@axe-core/playwright'
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual regression tests for OsSpinner component
|
||||||
|
*
|
||||||
|
* These tests capture screenshots of Storybook stories and compare them
|
||||||
|
* against baseline images to detect unintended visual changes.
|
||||||
|
* Each test also runs accessibility checks using axe-core.
|
||||||
|
*
|
||||||
|
* Note: Spinner animations are paused via Playwright's CSS override
|
||||||
|
* to ensure deterministic screenshots.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORY_URL = '/iframe.html?id=components-osspinner'
|
||||||
|
const STORY_ROOT = '#storybook-root'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for fonts and pause animations for deterministic screenshots
|
||||||
|
*/
|
||||||
|
async function waitForReady(page: Page) {
|
||||||
|
await page.evaluate(async () => document.fonts.ready)
|
||||||
|
// Pause all animations for deterministic screenshots
|
||||||
|
await page.addStyleTag({
|
||||||
|
content: '*, *::before, *::after { animation-play-state: paused !important; }',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to run accessibility check on the current page
|
||||||
|
*/
|
||||||
|
async function checkA11y(page: Page) {
|
||||||
|
const results = await new AxeBuilder({ page }).include(STORY_ROOT).analyze()
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([])
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('OsSpinner keyboard accessibility', () => {
|
||||||
|
test('spinner is not focusable and has status role', async ({ page }) => {
|
||||||
|
await page.goto(`${STORY_URL}--all-sizes&viewMode=story`)
|
||||||
|
const root = page.locator(STORY_ROOT)
|
||||||
|
await root.waitFor()
|
||||||
|
|
||||||
|
const spinners = root.locator('.os-spinner')
|
||||||
|
const count = await spinners.count()
|
||||||
|
|
||||||
|
expect(count).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const spinner = spinners.nth(i)
|
||||||
|
|
||||||
|
await expect(spinner).toHaveAttribute('role', 'status')
|
||||||
|
await expect(spinner).not.toHaveAttribute('tabindex')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no spinner receives focus via Tab navigation
|
||||||
|
await page.keyboard.press('Tab')
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await expect(spinners.nth(i)).not.toBeFocused()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('OsSpinner visual regression', () => {
|
||||||
|
test('all sizes', async ({ page }) => {
|
||||||
|
await page.goto(`${STORY_URL}--all-sizes&viewMode=story`)
|
||||||
|
const root = page.locator(STORY_ROOT)
|
||||||
|
await root.waitFor()
|
||||||
|
await waitForReady(page)
|
||||||
|
|
||||||
|
await expect(root.locator('[data-testid="all-sizes"]')).toHaveScreenshot('all-sizes.png')
|
||||||
|
|
||||||
|
await checkA11y(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inherit color', async ({ page }) => {
|
||||||
|
await page.goto(`${STORY_URL}--inherit-color&viewMode=story`)
|
||||||
|
const root = page.locator(STORY_ROOT)
|
||||||
|
await root.waitFor()
|
||||||
|
await waitForReady(page)
|
||||||
|
|
||||||
|
await expect(root.locator('[data-testid="inherit-color"]')).toHaveScreenshot(
|
||||||
|
'inherit-color.png',
|
||||||
|
)
|
||||||
|
|
||||||
|
await checkA11y(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('inline with text', async ({ page }) => {
|
||||||
|
await page.goto(`${STORY_URL}--inline-with-text&viewMode=story`)
|
||||||
|
const root = page.locator(STORY_ROOT)
|
||||||
|
await root.waitFor()
|
||||||
|
await waitForReady(page)
|
||||||
|
|
||||||
|
await expect(root.locator('[data-testid="inline-text"]')).toHaveScreenshot('inline-text.png')
|
||||||
|
|
||||||
|
await checkA11y(page)
|
||||||
|
})
|
||||||
|
})
|
||||||
120
packages/ui/src/components/OsSpinner/OsSpinner.vue
Normal file
120
packages/ui/src/components/OsSpinner/OsSpinner.vue
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, getCurrentInstance, h, isVue2 } from 'vue-demi'
|
||||||
|
|
||||||
|
import { cn } from '#src/utils'
|
||||||
|
|
||||||
|
import { SPINNER_SIZES } from './spinner.variants'
|
||||||
|
|
||||||
|
import type { Size } from '#src/types'
|
||||||
|
import type { ClassValue } from 'clsx'
|
||||||
|
import type { PropType } from 'vue-demi'
|
||||||
|
|
||||||
|
const SVG_ATTRS = {
|
||||||
|
viewBox: '0 0 50 50',
|
||||||
|
xmlns: 'http://www.w3.org/2000/svg',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CIRCLE_ATTRS = {
|
||||||
|
cx: '25',
|
||||||
|
cy: '25',
|
||||||
|
r: '20',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
'stroke-width': '4',
|
||||||
|
'stroke-linecap': 'round',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CIRCLE_STYLE =
|
||||||
|
'transform-origin:25px 25px;animation:os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite'
|
||||||
|
|
||||||
|
function vueAttrs(attrs: Record<string, string>, style: string): Record<string, unknown> {
|
||||||
|
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
|
||||||
|
return isVue2 ? { attrs, style } : { ...attrs, style }
|
||||||
|
/* v8 ignore stop */
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSvg() {
|
||||||
|
const circle = h('circle', vueAttrs(CIRCLE_ATTRS, CIRCLE_STYLE))
|
||||||
|
return h('svg', vueAttrs(SVG_ATTRS, 'width:100%;height:100%;overflow:hidden'), [circle])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated loading spinner with configurable size.
|
||||||
|
* Inherits color from parent via `currentColor`.
|
||||||
|
*
|
||||||
|
* Semantic by default (`role="status"`, `aria-label="Loading"`).
|
||||||
|
* Pass `aria-hidden="true"` to make it decorative (suppresses role/aria-label).
|
||||||
|
*
|
||||||
|
* @prop size - Spinner size (xs/sm/md/lg/xl/2xl)
|
||||||
|
*/
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'OsSpinner',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
size: {
|
||||||
|
type: String as PropType<Size>,
|
||||||
|
default: 'md',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { attrs }) {
|
||||||
|
/* v8 ignore start -- Vue 2 only */
|
||||||
|
const instance = isVue2 ? getCurrentInstance() : null
|
||||||
|
/* v8 ignore stop */
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const sizeClass = SPINNER_SIZES[props.size]
|
||||||
|
const svg = createSvg()
|
||||||
|
|
||||||
|
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
|
||||||
|
if (isVue2) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const proxy = instance?.proxy as any
|
||||||
|
const parentClass = proxy?.$vnode?.data?.staticClass || ''
|
||||||
|
const parentDynClass = proxy?.$vnode?.data?.class
|
||||||
|
const parentAttrs = proxy?.$vnode?.data?.attrs || {}
|
||||||
|
|
||||||
|
const isDecorative =
|
||||||
|
String(parentAttrs['aria-hidden']) === 'true' || String(attrs['aria-hidden']) === 'true'
|
||||||
|
const a11yAttrs = isDecorative
|
||||||
|
? { 'aria-hidden': 'true' }
|
||||||
|
: {
|
||||||
|
role: 'status',
|
||||||
|
'aria-label': parentAttrs['aria-label'] || attrs['aria-label'] || 'Loading',
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
class: cn('os-spinner inline-flex shrink-0', sizeClass, parentClass, parentDynClass),
|
||||||
|
attrs: { ...parentAttrs, ...attrs, ...a11yAttrs },
|
||||||
|
},
|
||||||
|
[svg],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/* v8 ignore stop */
|
||||||
|
|
||||||
|
const {
|
||||||
|
class: attrClass,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
'aria-hidden': ariaHidden,
|
||||||
|
...restAttrs
|
||||||
|
} = attrs as Record<string, unknown>
|
||||||
|
|
||||||
|
const isDecorative = String(ariaHidden) === 'true'
|
||||||
|
const a11yAttrs = isDecorative
|
||||||
|
? { 'aria-hidden': 'true' as const }
|
||||||
|
: { role: 'status' as const, 'aria-label': (ariaLabel as string) || 'Loading' }
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
class: cn('os-spinner inline-flex shrink-0', sizeClass, attrClass as ClassValue),
|
||||||
|
...restAttrs,
|
||||||
|
...a11yAttrs,
|
||||||
|
},
|
||||||
|
[svg],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
2
packages/ui/src/components/OsSpinner/index.ts
Normal file
2
packages/ui/src/components/OsSpinner/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as OsSpinner } from './OsSpinner.vue'
|
||||||
|
export { SPINNER_SIZES } from './spinner.variants'
|
||||||
13
packages/ui/src/components/OsSpinner/spinner.variants.ts
Normal file
13
packages/ui/src/components/OsSpinner/spinner.variants.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import type { Size } from '#src/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spinner size classes (em-based, scales with parent font-size)
|
||||||
|
*/
|
||||||
|
export const SPINNER_SIZES: Record<Size, string> = {
|
||||||
|
xs: 'h-[0.75em] w-[0.75em]',
|
||||||
|
sm: 'h-[1em] w-[1em]',
|
||||||
|
md: 'h-[1.5em] w-[1.5em]',
|
||||||
|
lg: 'h-[2em] w-[2em]',
|
||||||
|
xl: 'h-[2.5em] w-[2.5em]',
|
||||||
|
'2xl': 'h-[3em] w-[3em]',
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@
|
|||||||
* - Registered globally when using the plugin: app.use(OcelotUI)
|
* - Registered globally when using the plugin: app.use(OcelotUI)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { OsButton, buttonVariants, type ButtonVariants } from './OsButton'
|
export { OsButton, buttonVariants, type ButtonSize, type ButtonVariants } from './OsButton'
|
||||||
export {
|
export {
|
||||||
OsIcon,
|
OsIcon,
|
||||||
ICON_SIZES,
|
ICON_SIZES,
|
||||||
@ -17,3 +17,4 @@ export {
|
|||||||
SYSTEM_ICONS,
|
SYSTEM_ICONS,
|
||||||
type SystemIconName,
|
type SystemIconName,
|
||||||
} from './OsIcon'
|
} from './OsIcon'
|
||||||
|
export { OsSpinner, SPINNER_SIZES } from './OsSpinner'
|
||||||
|
|||||||
12
packages/ui/src/types.d.ts
vendored
12
packages/ui/src/types.d.ts
vendored
@ -1,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Component prop types based on Tailwind CSS scales
|
* Shared vocabulary types for component props.
|
||||||
*
|
*
|
||||||
* These types ensure consistency across all components.
|
* These types define a common naming convention across components.
|
||||||
* When a component supports a prop, it must support all values of that scale.
|
* Components pick the subset that makes sense for them:
|
||||||
|
* - OsIcon/OsSpinner: all 6 sizes (inline elements, em-based)
|
||||||
|
* - OsButton: sm–xl (interactive element, pixel-based touch targets)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Size scale for components (buttons, inputs, avatars, etc.)
|
* Size vocabulary shared across components.
|
||||||
* Maps to Tailwind's text/spacing scale
|
* Each component supports the subset that makes sense for its context.
|
||||||
*/
|
*/
|
||||||
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||||
|
|
||||||
|
|||||||
@ -13,9 +13,8 @@ RUN apk --no-cache add git python3 make g++
|
|||||||
RUN mkdir -p /app
|
RUN mkdir -p /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY packages/ui .
|
COPY packages/ui .
|
||||||
RUN --mount=type=cache,target=/yarn-cache,sharing=locked \
|
RUN npm ci
|
||||||
yarn install --production=false --frozen-lockfile --non-interactive --cache-folder /yarn-cache
|
RUN npm run build
|
||||||
RUN yarn run build
|
|
||||||
|
|
||||||
FROM node:25.6.1-alpine AS base
|
FROM node:25.6.1-alpine AS base
|
||||||
LABEL org.label-schema.name="ocelot.social:webapp"
|
LABEL org.label-schema.name="ocelot.social:webapp"
|
||||||
|
|||||||
@ -23,9 +23,8 @@ RUN apk --no-cache add git python3 make g++
|
|||||||
RUN mkdir -p /app
|
RUN mkdir -p /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY packages/ui .
|
COPY packages/ui .
|
||||||
RUN --mount=type=cache,target=/yarn-cache,sharing=locked \
|
RUN npm ci
|
||||||
yarn install --production=false --frozen-lockfile --non-interactive --cache-folder /yarn-cache
|
RUN npm run build
|
||||||
RUN yarn run build
|
|
||||||
|
|
||||||
FROM node:25.6.1-alpine AS build
|
FROM node:25.6.1-alpine AS build
|
||||||
ENV NODE_ENV="production"
|
ENV NODE_ENV="production"
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
:use-custom-slot="true"
|
:use-custom-slot="true"
|
||||||
@vdropzone-file-added="fileAdded"
|
@vdropzone-file-added="fileAdded"
|
||||||
>
|
>
|
||||||
<loading-spinner v-if="isLoadingImage" />
|
<os-spinner v-if="isLoadingImage" size="lg" />
|
||||||
<os-icon v-else-if="!hasImage" :icon="icons.image" />
|
<os-icon v-else-if="!hasImage" :icon="icons.image" />
|
||||||
<div v-if="!hasImage" class="supported-formats">
|
<div v-if="!hasImage" class="supported-formats">
|
||||||
{{ $t('contribution.teaserImage.supportedFormats') }}
|
{{ $t('contribution.teaserImage.supportedFormats') }}
|
||||||
@ -58,20 +58,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
import { OsButton, OsIcon, OsSpinner } from '@ocelot-social/ui'
|
||||||
import { iconRegistry } from '~/utils/iconRegistry'
|
import { iconRegistry } from '~/utils/iconRegistry'
|
||||||
import Cropper from 'cropperjs'
|
import Cropper from 'cropperjs'
|
||||||
import VueDropzone from 'nuxt-dropzone'
|
import VueDropzone from 'nuxt-dropzone'
|
||||||
import LoadingSpinner from '~/components/_new/generic/LoadingSpinner/LoadingSpinner'
|
|
||||||
import 'cropperjs/dist/cropper.css'
|
import 'cropperjs/dist/cropper.css'
|
||||||
|
|
||||||
const minAspectRatio = 0.3
|
const minAspectRatio = 0.3
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
LoadingSpinner,
|
|
||||||
OsButton,
|
OsButton,
|
||||||
OsIcon,
|
OsIcon,
|
||||||
|
OsSpinner,
|
||||||
VueDropzone,
|
VueDropzone,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
import { storiesOf } from '@storybook/vue'
|
|
||||||
import helpers from '~/storybook/helpers'
|
|
||||||
import LoadingSpinner from './LoadingSpinner.vue'
|
|
||||||
|
|
||||||
storiesOf('Generic/LoadingSpinner', module)
|
|
||||||
.addDecorator(helpers.layout)
|
|
||||||
|
|
||||||
.add('default', () => ({
|
|
||||||
components: { LoadingSpinner },
|
|
||||||
template: '<loading-spinner />',
|
|
||||||
}))
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg viewBox="0 0 50 50" class="loading-spinner">
|
|
||||||
<circle cx="25" cy="25" r="20" class="circle" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'LoadingSpinner',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.loading-spinner {
|
|
||||||
height: $size-button-base;
|
|
||||||
overflow: hidden;
|
|
||||||
stroke: currentColor;
|
|
||||||
animation: rotate 16s linear infinite;
|
|
||||||
|
|
||||||
> .circle {
|
|
||||||
fill: none;
|
|
||||||
stroke-width: 5;
|
|
||||||
stroke-linecap: round;
|
|
||||||
animation: dash 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dash {
|
|
||||||
0% {
|
|
||||||
stroke-dasharray: 1, 150;
|
|
||||||
stroke-dashoffset: 0;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
stroke-dasharray: 90, 150;
|
|
||||||
stroke-dashoffset: -35;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
stroke-dasharray: 90, 150;
|
|
||||||
stroke-dashoffset: -124;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rotate {
|
|
||||||
100% {
|
|
||||||
transform: rotate(2160deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,69 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<base-card>
|
<base-card>
|
||||||
<ApolloQuery :query="Statistics">
|
<template v-if="$apollo.loading">
|
||||||
<template v-slot="{ result: { loading, error, data } }">
|
<div style="text-align: center; padding: 48px 0">
|
||||||
<template v-if="loading">
|
<os-spinner size="xl" />
|
||||||
<ds-space centered>
|
</div>
|
||||||
<ds-spinner size="large"></ds-spinner>
|
</template>
|
||||||
</ds-space>
|
<template v-else-if="statistics">
|
||||||
</template>
|
<ds-space margin="large">
|
||||||
<template v-else-if="error">
|
<ds-flex>
|
||||||
<ds-space centered>
|
<ds-flex-item
|
||||||
<ds-space>
|
v-for="(value, name) in filterStatistics(statistics)"
|
||||||
<img :src="errorIconPath" width="40" />
|
:key="name"
|
||||||
|
:width="{ base: '100%', sm: '50%', md: '33%' }"
|
||||||
|
>
|
||||||
|
<ds-space margin="small">
|
||||||
|
<ds-number :count="0" :label="$t('admin.dashboard.' + name)" size="x-large" uppercase>
|
||||||
|
<client-only slot="count">
|
||||||
|
<hc-count-to :end-val="value" />
|
||||||
|
</client-only>
|
||||||
|
</ds-number>
|
||||||
</ds-space>
|
</ds-space>
|
||||||
<ds-text>
|
</ds-flex-item>
|
||||||
{{ $t('site.error-occurred') }}
|
</ds-flex>
|
||||||
</ds-text>
|
</ds-space>
|
||||||
</ds-space>
|
</template>
|
||||||
</template>
|
<template v-else>
|
||||||
<template v-else-if="data">
|
<ds-space centered>
|
||||||
<ds-space margin="large">
|
<ds-space>
|
||||||
<ds-flex>
|
<img :src="errorIconPath" width="40" />
|
||||||
<ds-flex-item
|
</ds-space>
|
||||||
v-for="(value, name, index) in filterStatistics(data.statistics)"
|
<ds-text>
|
||||||
:key="index"
|
{{ $t('site.error-occurred') }}
|
||||||
:width="{ base: '100%', sm: '50%', md: '33%' }"
|
</ds-text>
|
||||||
>
|
</ds-space>
|
||||||
<ds-space margin="small">
|
</template>
|
||||||
<ds-number
|
|
||||||
:count="0"
|
|
||||||
:label="$t('admin.dashboard.' + name)"
|
|
||||||
size="x-large"
|
|
||||||
uppercase
|
|
||||||
>
|
|
||||||
<client-only slot="count">
|
|
||||||
<hc-count-to :end-val="value" />
|
|
||||||
</client-only>
|
|
||||||
</ds-number>
|
|
||||||
</ds-space>
|
|
||||||
</ds-flex-item>
|
|
||||||
</ds-flex>
|
|
||||||
</ds-space>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</ApolloQuery>
|
|
||||||
</base-card>
|
</base-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { OsSpinner } from '@ocelot-social/ui'
|
||||||
import HcCountTo from '~/components/CountTo.vue'
|
import HcCountTo from '~/components/CountTo.vue'
|
||||||
import { Statistics } from '~/graphql/admin/Statistics'
|
import { Statistics } from '~/graphql/admin/Statistics'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
OsSpinner,
|
||||||
HcCountTo,
|
HcCountTo,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
errorIconPath: '/img/svg/emoji/cry.svg',
|
errorIconPath: '/img/svg/emoji/cry.svg',
|
||||||
Statistics,
|
statistics: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
apollo: {
|
||||||
|
statistics: {
|
||||||
|
query: Statistics,
|
||||||
|
update(data) {
|
||||||
|
return data.statistics
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
filterStatistics(data) {
|
filterStatistics(data) {
|
||||||
delete data.__typename
|
const { __typename, ...rest } = data
|
||||||
return data
|
return rest
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1071,13 +1071,31 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -1582,13 +1600,31 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -2113,13 +2149,31 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -3147,13 +3201,31 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a close
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -4200,13 +4272,31 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -5014,13 +5104,31 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -5848,13 +5956,31 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -6812,13 +6938,31 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a curre
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -7952,13 +8096,31 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a hidde
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -9015,13 +9177,31 @@ exports[`GroupProfileSlug given a puplic group – "yoga-practice" given a hidde
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -268,9 +268,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="$apollo.loading">
|
<template v-else-if="$apollo.loading">
|
||||||
<ds-grid-item column-span="fullWidth">
|
<ds-grid-item column-span="fullWidth">
|
||||||
<ds-space centered>
|
<div style="text-align: center; padding: 48px 0">
|
||||||
<ds-spinner size="base"></ds-spinner>
|
<os-spinner size="lg" />
|
||||||
</ds-space>
|
</div>
|
||||||
</ds-grid-item>
|
</ds-grid-item>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@ -280,7 +280,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</masonry-grid>
|
</masonry-grid>
|
||||||
<client-only>
|
<client-only>
|
||||||
<infinite-loading v-if="hasMore" @infinite="showMoreContributions" />
|
<infinite-loading v-if="hasMore" @infinite="showMoreContributions">
|
||||||
|
<os-spinner slot="spinner" size="lg" />
|
||||||
|
</infinite-loading>
|
||||||
</client-only>
|
</client-only>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
</ds-flex>
|
</ds-flex>
|
||||||
@ -288,7 +290,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
import { OsButton, OsIcon, OsSpinner } from '@ocelot-social/ui'
|
||||||
import { iconRegistry } from '~/utils/iconRegistry'
|
import { iconRegistry } from '~/utils/iconRegistry'
|
||||||
import uniqBy from 'lodash/uniqBy'
|
import uniqBy from 'lodash/uniqBy'
|
||||||
import { profilePagePosts } from '~/graphql/PostQuery'
|
import { profilePagePosts } from '~/graphql/PostQuery'
|
||||||
@ -327,6 +329,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
OsButton,
|
OsButton,
|
||||||
OsIcon,
|
OsIcon,
|
||||||
|
OsSpinner,
|
||||||
AvatarUploader,
|
AvatarUploader,
|
||||||
Category,
|
Category,
|
||||||
ContentViewer,
|
ContentViewer,
|
||||||
|
|||||||
@ -146,13 +146,15 @@
|
|||||||
|
|
||||||
<!-- infinite loading -->
|
<!-- infinite loading -->
|
||||||
<client-only>
|
<client-only>
|
||||||
<infinite-loading v-if="hasMore" @infinite="showMoreContributions" />
|
<infinite-loading v-if="hasMore" @infinite="showMoreContributions">
|
||||||
|
<os-spinner slot="spinner" size="lg" />
|
||||||
|
</infinite-loading>
|
||||||
</client-only>
|
</client-only>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
import { OsButton, OsIcon, OsSpinner } from '@ocelot-social/ui'
|
||||||
import { iconRegistry } from '~/utils/iconRegistry'
|
import { iconRegistry } from '~/utils/iconRegistry'
|
||||||
import postListActions from '~/mixins/postListActions'
|
import postListActions from '~/mixins/postListActions'
|
||||||
import mobile from '~/mixins/mobile'
|
import mobile from '~/mixins/mobile'
|
||||||
@ -178,6 +180,7 @@ export default {
|
|||||||
HashtagsFilter,
|
HashtagsFilter,
|
||||||
OsButton,
|
OsButton,
|
||||||
OsIcon,
|
OsIcon,
|
||||||
|
OsSpinner,
|
||||||
PostTeaser,
|
PostTeaser,
|
||||||
HcEmpty,
|
HcEmpty,
|
||||||
MasonryGrid,
|
MasonryGrid,
|
||||||
|
|||||||
@ -679,13 +679,31 @@ exports[`ProfileSlug given an authenticated user given another profile user and
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -1447,13 +1465,31 @@ exports[`ProfileSlug given an authenticated user given another profile user and
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -2076,13 +2112,31 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -2747,13 +2801,31 @@ exports[`ProfileSlug given an authenticated user given the logged in user as pro
|
|||||||
<div
|
<div
|
||||||
class="infinite-status-prompt"
|
class="infinite-status-prompt"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
style="color: rgb(102, 102, 102); font-size: 14px; padding: 10px 0px; display: none;"
|
style="display: none;"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="loading-default"
|
aria-label="Loading"
|
||||||
data-v-46b20d22=""
|
class="os-spinner inline-flex shrink-0 h-[2em] w-[2em]"
|
||||||
data-v-644ea9c9=""
|
data-v-644ea9c9=""
|
||||||
/>
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style="width: 100%; height: 100%; overflow: hidden;"
|
||||||
|
viewBox="0 0 50 50"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="25"
|
||||||
|
fill="none"
|
||||||
|
r="20"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="4"
|
||||||
|
style="transform-origin: 25px 25px; animation: os-spinner-rotate 16s linear infinite,os-spinner-dash 1.5s ease-in-out infinite;"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -190,9 +190,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="$apollo.loading">
|
<template v-else-if="$apollo.loading">
|
||||||
<ds-grid-item column-span="fullWidth">
|
<ds-grid-item column-span="fullWidth">
|
||||||
<ds-space centered>
|
<div style="text-align: center; padding: 48px 0">
|
||||||
<ds-spinner size="base"></ds-spinner>
|
<os-spinner size="lg" />
|
||||||
</ds-space>
|
</div>
|
||||||
</ds-grid-item>
|
</ds-grid-item>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@ -202,7 +202,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</masonry-grid>
|
</masonry-grid>
|
||||||
<client-only>
|
<client-only>
|
||||||
<infinite-loading v-if="hasMore" @infinite="showMoreContributions" />
|
<infinite-loading v-if="hasMore" @infinite="showMoreContributions">
|
||||||
|
<os-spinner slot="spinner" size="lg" />
|
||||||
|
</infinite-loading>
|
||||||
</client-only>
|
</client-only>
|
||||||
</ds-flex-item>
|
</ds-flex-item>
|
||||||
</ds-flex>
|
</ds-flex>
|
||||||
@ -210,7 +212,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
import { OsButton, OsIcon, OsSpinner } from '@ocelot-social/ui'
|
||||||
import { iconRegistry } from '~/utils/iconRegistry'
|
import { iconRegistry } from '~/utils/iconRegistry'
|
||||||
import uniqBy from 'lodash/uniqBy'
|
import uniqBy from 'lodash/uniqBy'
|
||||||
import { mapGetters, mapMutations } from 'vuex'
|
import { mapGetters, mapMutations } from 'vuex'
|
||||||
@ -247,6 +249,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
OsButton,
|
OsButton,
|
||||||
OsIcon,
|
OsIcon,
|
||||||
|
OsSpinner,
|
||||||
SocialMedia,
|
SocialMedia,
|
||||||
PostTeaser,
|
PostTeaser,
|
||||||
HcFollowButton,
|
HcFollowButton,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user