enhance(client): Sync widgets (#8512)
* feature: sync widgets among devices * fix * nanka iroiro * classic.widgets.vueの機能をuniversal.widgets.vueに統合 * 左右のウィジェット編集状態を同期するように * 左右やカラム間でウィジェットを行き来できるように * MkWidgetsをCSS Module化 * set min-height: 100px; * fix deck widget * Update packages/client/src/ui/deck/widgets-column.vue Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> * merge * Update classic.vue * Delete classic.widgets.vue Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
		
							parent
							
								
									6c4fa1bc8b
								
							
						
					
					
						commit
						e3f2845cf8
					
				
					 8 changed files with 125 additions and 155 deletions
				
			
		|  | @ -1 +1 @@ | ||||||
| Subproject commit cf3ce27b2eb8417233072e3d6d2fb7c5356c2364 | Subproject commit 0179793ec891856d6f37a3be16ba4c22f67a81b5 | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| <template> | <template> | ||||||
| <div class="vjoppmmu"> | <div :class="$style.root"> | ||||||
| 	<template v-if="edit"> | 	<template v-if="edit"> | ||||||
| 		<header> | 		<header :class="$style['edit-header']"> | ||||||
| 			<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" class="mk-widget-select"> | 			<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" class="mk-widget-select"> | ||||||
| 				<template #label>{{ i18n.ts.selectWidget }}</template> | 				<template #label>{{ i18n.ts.selectWidget }}</template> | ||||||
| 				<option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option> | 				<option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option> | ||||||
|  | @ -14,23 +14,34 @@ | ||||||
| 			item-key="id" | 			item-key="id" | ||||||
| 			handle=".handle" | 			handle=".handle" | ||||||
| 			:animation="150" | 			:animation="150" | ||||||
|  | 			:group="{ name: 'SortableMkWidgets' }" | ||||||
| 			@update:model-value="v => emit('updateWidgets', v)" | 			@update:model-value="v => emit('updateWidgets', v)" | ||||||
|  | 			:class="$style['edit-editing']" | ||||||
| 		> | 		> | ||||||
| 			<template #item="{element}"> | 			<template #item="{element}"> | ||||||
| 				<div class="customize-container"> | 				<div :class="[$style.widget, $style['customize-container']]"> | ||||||
| 					<button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button> | 					<button :class="$style['customize-container-config']" class="_button" @click.prevent.stop="configWidget(element.id)"><i class="ti ti-settings"></i></button> | ||||||
| 					<button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button> | 					<button :class="$style['customize-container-remove']" class="_button" @click.prevent.stop="removeWidget(element)"><i class="ti ti-x"></i></button> | ||||||
| 					<div class="handle"> | 					<div class="handle"> | ||||||
| 						<component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :widget="element" @update-props="updateWidget(element.id, $event)"/> | 						<component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :class="$style['customize-container-handle-widget']" :widget="element" @update-props="updateWidget(element.id, $event)"/> | ||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</div> | ||||||
| 			</template> | 			</template> | ||||||
| 		</Sortable> | 		</Sortable> | ||||||
| 	</template> | 	</template> | ||||||
| 	<component :is="`mkw-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" class="widget" :widget="widget" @update-props="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> | 	<component :is="`mkw-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @update-props="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/> | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | <script lang="ts"> | ||||||
|  | export type Widget = { | ||||||
|  | 	name: string; | ||||||
|  | 	id: string; | ||||||
|  | 	data: Record<string, any>; | ||||||
|  | }; | ||||||
|  | export type DefaultStoredWidget = { | ||||||
|  | 	place: string | null; | ||||||
|  | } & Widget; | ||||||
|  | </script> | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { defineAsyncComponent, reactive, ref, computed } from 'vue'; | import { defineAsyncComponent, reactive, ref, computed } from 'vue'; | ||||||
| import { v4 as uuid } from 'uuid'; | import { v4 as uuid } from 'uuid'; | ||||||
|  | @ -43,12 +54,6 @@ import { deepClone } from '@/scripts/clone'; | ||||||
| 
 | 
 | ||||||
| const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); | const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); | ||||||
| 
 | 
 | ||||||
| type Widget = { |  | ||||||
| 	name: string; |  | ||||||
| 	id: string; |  | ||||||
| 	data: Record<string, any>; |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const props = defineProps<{ | const props = defineProps<{ | ||||||
| 	widgets: Widget[]; | 	widgets: Widget[]; | ||||||
| 	edit: boolean; | 	edit: boolean; | ||||||
|  | @ -109,20 +114,12 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <style lang="scss" scoped> | <style lang="scss" module> | ||||||
| .vjoppmmu { | .root { | ||||||
| 	container-type: inline-size; | 	container-type: inline-size; | ||||||
| 
 |  | ||||||
| 	> header { |  | ||||||
| 		margin: 16px 0; |  | ||||||
| 
 |  | ||||||
| 		> * { |  | ||||||
| 			width: 100%; |  | ||||||
| 			padding: 4px; |  | ||||||
| 		} |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| 	> .widget, .customize-container { | .widget { | ||||||
| 	contain: content; | 	contain: content; | ||||||
| 	margin: var(--margin) 0; | 	margin: var(--margin) 0; | ||||||
| 
 | 
 | ||||||
|  | @ -131,12 +128,27 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .edit { | ||||||
|  | 	&-header { | ||||||
|  | 		margin: 16px 0; | ||||||
|  | 
 | ||||||
|  | 		> * { | ||||||
|  | 			width: 100%; | ||||||
|  | 			padding: 4px; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	&-editing { | ||||||
|  | 		min-height: 100px; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .customize-container { | .customize-container { | ||||||
| 	position: relative; | 	position: relative; | ||||||
| 	cursor: move; | 	cursor: move; | ||||||
| 
 | 
 | ||||||
| 		> .config, | 	&-config, | ||||||
| 		> .remove { | 	&-remove { | ||||||
| 		position: absolute; | 		position: absolute; | ||||||
| 		z-index: 10000; | 		z-index: 10000; | ||||||
| 		top: 8px; | 		top: 8px; | ||||||
|  | @ -147,19 +159,21 @@ function onContextmenu(widget: Widget, ev: MouseEvent) { | ||||||
| 		border-radius: 4px; | 		border-radius: 4px; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 		> .config { | 	&-config { | ||||||
| 		right: 8px + 8px + 32px; | 		right: 8px + 8px + 32px; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 		> .remove { | 	&-remove { | ||||||
| 		right: 8px; | 		right: 8px; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 		> .handle { | 	&-handle { | ||||||
| 			> .widget { | 
 | ||||||
|  | 		&-widget { | ||||||
| 			pointer-events: none; | 			pointer-events: none; | ||||||
| 		}  | 		}  | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
| } | } | ||||||
| } | 
 | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
|  | @ -122,7 +122,7 @@ export const defaultStore = markRaw(new Storage('base', { | ||||||
| 		}[], | 		}[], | ||||||
| 	}, | 	}, | ||||||
| 	widgets: { | 	widgets: { | ||||||
| 		where: 'deviceAccount', | 		where: 'account', | ||||||
| 		default: [] as { | 		default: [] as { | ||||||
| 			name: string; | 			name: string; | ||||||
| 			id: string; | 			id: string; | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ | ||||||
| 			<XSidebar/> | 			<XSidebar/> | ||||||
| 		</div> | 		</div> | ||||||
| 		<div v-else ref="widgetsLeft" class="widgets left"> | 		<div v-else ref="widgetsLeft" class="widgets left"> | ||||||
| 			<XWidgets :place="'left'" @mounted="attachSticky(widgetsLeft)"/> | 			<XWidgets place="left" @mounted="attachSticky(widgetsLeft)"/> | ||||||
| 		</div> | 		</div> | ||||||
| 
 | 
 | ||||||
| 		<main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> | 		<main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> | ||||||
|  | @ -17,7 +17,7 @@ | ||||||
| 		</main> | 		</main> | ||||||
| 
 | 
 | ||||||
| 		<div v-if="isDesktop" ref="widgetsRight" class="widgets right"> | 		<div v-if="isDesktop" ref="widgetsRight" class="widgets right"> | ||||||
| 			<XWidgets :place="null" @mounted="attachSticky(widgetsRight)"/> | 			<XWidgets :place="showMenuOnTop ? 'right' : null" @mounted="attachSticky(widgetsRight)"/> | ||||||
| 		</div> | 		</div> | ||||||
| 	</div> | 	</div> | ||||||
| 
 | 
 | ||||||
|  | @ -52,7 +52,7 @@ import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/script | ||||||
| import { defaultStore } from '@/store'; | import { defaultStore } from '@/store'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue')); | const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue')); | ||||||
| const XWidgets = defineAsyncComponent(() => import('./classic.widgets.vue')); | const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); | ||||||
| 
 | 
 | ||||||
| const DESKTOP_THRESHOLD = 1100; | const DESKTOP_THRESHOLD = 1100; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,84 +0,0 @@ | ||||||
| <template> |  | ||||||
| <div class="ddiqwdnk"> |  | ||||||
| 	<XWidgets class="widgets" :edit="editMode" :widgets="$store.reactiveState.widgets.value.filter(w => w.place === place)" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> |  | ||||||
| 	<MkAd class="a" :prefer="['square']"/> |  | ||||||
| 
 |  | ||||||
| 	<button v-if="editMode" class="_textButton edit" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ $ts.editWidgetsExit }}</button> |  | ||||||
| 	<button v-else class="_textButton edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ $ts.editWidgets }}</button> |  | ||||||
| </div> |  | ||||||
| </template> |  | ||||||
| 
 |  | ||||||
| <script lang="ts"> |  | ||||||
| import { defineComponent, defineAsyncComponent } from 'vue'; |  | ||||||
| import XWidgets from '@/components/MkWidgets.vue'; |  | ||||||
| 
 |  | ||||||
| export default defineComponent({ |  | ||||||
| 	components: { |  | ||||||
| 		XWidgets, |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	props: { |  | ||||||
| 		place: { |  | ||||||
| 			type: String, |  | ||||||
| 		}, |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	emits: ['mounted'], |  | ||||||
| 
 |  | ||||||
| 	data() { |  | ||||||
| 		return { |  | ||||||
| 			editMode: false, |  | ||||||
| 		}; |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	mounted() { |  | ||||||
| 		this.$emit('mounted', this.$el); |  | ||||||
| 	}, |  | ||||||
| 
 |  | ||||||
| 	methods: { |  | ||||||
| 		addWidget(widget) { |  | ||||||
| 			this.$store.set('widgets', [{ |  | ||||||
| 				...widget, |  | ||||||
| 				place: this.place, |  | ||||||
| 			}, ...this.$store.state.widgets]); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		removeWidget(widget) { |  | ||||||
| 			this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id !== widget.id)); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		updateWidget({ id, data }) { |  | ||||||
| 			this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? { |  | ||||||
| 				...w, |  | ||||||
| 				data, |  | ||||||
| 			} : w)); |  | ||||||
| 		}, |  | ||||||
| 
 |  | ||||||
| 		updateWidgets(widgets) { |  | ||||||
| 			this.$store.set('widgets', [ |  | ||||||
| 				...this.$store.state.widgets.filter(w => w.place !== this.place), |  | ||||||
| 				...widgets, |  | ||||||
| 			]); |  | ||||||
| 		}, |  | ||||||
| 	}, |  | ||||||
| }); |  | ||||||
| </script> |  | ||||||
| 
 |  | ||||||
| <style lang="scss" scoped> |  | ||||||
| .ddiqwdnk { |  | ||||||
| 	position: sticky; |  | ||||||
| 	height: min-content; |  | ||||||
| 	box-sizing: border-box; |  | ||||||
| 	padding-bottom: 8px; |  | ||||||
| 
 |  | ||||||
| 	> .widgets, |  | ||||||
| 	> .a { |  | ||||||
| 		width: 300px; |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	> .edit { |  | ||||||
| 		display: block; |  | ||||||
| 		margin: 16px auto; |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
|  | @ -4,7 +4,7 @@ | ||||||
| 
 | 
 | ||||||
| 	<div class="wtdtxvec"> | 	<div class="wtdtxvec"> | ||||||
| 		<div v-if="!(column.widgets && column.widgets.length > 0) && !edit" class="intro">{{ i18n.ts._deck.widgetsIntroduction }}</div> | 		<div v-if="!(column.widgets && column.widgets.length > 0) && !edit" class="intro">{{ i18n.ts._deck.widgetsIntroduction }}</div> | ||||||
| 		<XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> | 		<XWidgets :edit="edit" :widgets="column.widgets ?? []" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> | ||||||
| 	</div> | 	</div> | ||||||
| </XColumn> | </XColumn> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
|  | @ -273,7 +273,7 @@ const wallpaper = localStorage.getItem('wallpaper') != null; | ||||||
| 		right: 0; | 		right: 0; | ||||||
| 		z-index: 1001; | 		z-index: 1001; | ||||||
| 		height: 100dvh; | 		height: 100dvh; | ||||||
| 		padding: var(--margin); | 		padding: var(--margin) !important; | ||||||
| 		box-sizing: border-box; | 		box-sizing: border-box; | ||||||
| 		overflow: auto; | 		overflow: auto; | ||||||
| 		overscroll-behavior: contain; | 		overscroll-behavior: contain; | ||||||
|  |  | ||||||
|  | @ -1,25 +1,44 @@ | ||||||
| <template> | <template> | ||||||
| <div class="efzpzdvf"> | <div class="efzpzdvf" :class="{ universal: !classic, classic }"> | ||||||
| 	<XWidgets :edit="editMode" :widgets="defaultStore.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> | 	<XWidgets :edit="editMode" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> | ||||||
| 
 | 
 | ||||||
| 	<button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button> | 	<button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button> | ||||||
| 	<button v-else class="_textButton mk-widget-edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> | 	<button v-else class="_textButton mk-widget-edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> | ||||||
| </div> | </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
|  | <script lang="ts"> | ||||||
|  | let editMode = $ref(false); | ||||||
|  | </script> | ||||||
| <script lang="ts" setup> | <script lang="ts" setup> | ||||||
| import { onMounted } from 'vue'; | import { onMounted } from 'vue'; | ||||||
| import XWidgets from '@/components/MkWidgets.vue'; | import XWidgets from '@/components/MkWidgets.vue'; | ||||||
| import { i18n } from '@/i18n'; | import { i18n } from '@/i18n'; | ||||||
| import { defaultStore } from '@/store'; | import { defaultStore } from '@/store'; | ||||||
| 
 | 
 | ||||||
|  | const props = withDefaults(defineProps<{ | ||||||
|  | 	// null = 全てのウィジェットを表示 | ||||||
|  | 	// left = place: leftだけを表示 | ||||||
|  | 	// right = rightとnullを表示 | ||||||
|  | 	place?: 'left' | null | 'right'; | ||||||
|  | 	classic?: boolean; | ||||||
|  | }>(), { | ||||||
|  | 	place: null, | ||||||
|  | 	classic: false, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| const emit = defineEmits<{ | const emit = defineEmits<{ | ||||||
| 	(ev: 'mounted', el: Element): void; | 	(ev: 'mounted', el?: Element): void; | ||||||
| }>(); | }>(); | ||||||
| 
 | 
 | ||||||
| let editMode = $ref(false); |  | ||||||
| let rootEl = $ref<HTMLDivElement>(); | let rootEl = $ref<HTMLDivElement>(); | ||||||
| 
 | 
 | ||||||
|  | const widgets = $computed(() => { | ||||||
|  | 	if (props.place === null) return defaultStore.reactiveState.widgets.value; | ||||||
|  | 	if (props.place === 'left') return defaultStore.reactiveState.widgets.value.filter(w => w.place === 'left'); | ||||||
|  | 	return defaultStore.reactiveState.widgets.value.filter(w => w.place !== 'left'); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
| 	emit('mounted', rootEl); | 	emit('mounted', rootEl); | ||||||
| }); | }); | ||||||
|  | @ -27,7 +46,7 @@ onMounted(() => { | ||||||
| function addWidget(widget) { | function addWidget(widget) { | ||||||
| 	defaultStore.set('widgets', [{ | 	defaultStore.set('widgets', [{ | ||||||
| 		...widget, | 		...widget, | ||||||
| 		place: null, | 		place: props.place, | ||||||
| 	}, ...defaultStore.state.widgets]); | 	}, ...defaultStore.state.widgets]); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -39,11 +58,26 @@ function updateWidget({ id, data }) { | ||||||
| 	defaultStore.set('widgets', defaultStore.state.widgets.map(w => w.id === id ? { | 	defaultStore.set('widgets', defaultStore.state.widgets.map(w => w.id === id ? { | ||||||
| 		...w, | 		...w, | ||||||
| 		data, | 		data, | ||||||
|  | 		place: props.place, | ||||||
| 	} : w)); | 	} : w)); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function updateWidgets(widgets) { | function updateWidgets(thisWidgets) { | ||||||
| 	defaultStore.set('widgets', widgets); | 	if (props.place === null) { | ||||||
|  | 		defaultStore.set('widgets', thisWidgets); | ||||||
|  | 		return; | ||||||
|  | 	} | ||||||
|  | 	if (props.place === 'left') { | ||||||
|  | 		defaultStore.set('widgets', [ | ||||||
|  | 			...thisWidgets.map(w => ({ ...w, place: 'left' })), | ||||||
|  | 			...defaultStore.state.widgets.filter(w => w.place !== 'left' && !thisWidgets.some(t => w.id === t.id)), | ||||||
|  | 		]); | ||||||
|  | 		return; | ||||||
|  | 	} | ||||||
|  | 	defaultStore.set('widgets', [ | ||||||
|  | 		...defaultStore.state.widgets.filter(w => w.place === 'left' && !thisWidgets.some(t => w.id === t.id)), | ||||||
|  | 		...thisWidgets.map(w => ({ ...w, place: 'right' })), | ||||||
|  | 	]); | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
|  | @ -52,11 +86,17 @@ function updateWidgets(widgets) { | ||||||
| 	position: sticky; | 	position: sticky; | ||||||
| 	height: min-content; | 	height: min-content; | ||||||
| 	min-height: 100vh; | 	min-height: 100vh; | ||||||
| 	padding: var(--margin) 0; |  | ||||||
| 	box-sizing: border-box; | 	box-sizing: border-box; | ||||||
| 
 | 
 | ||||||
|  | 	&.universal { | ||||||
|  | 		padding: var(--margin) 0; | ||||||
|  | 
 | ||||||
| 		> * { | 		> * { | ||||||
| 			margin: var(--margin) 0; | 			margin: var(--margin) 0; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	> * { | ||||||
| 		width: 300px; | 		width: 300px; | ||||||
| 
 | 
 | ||||||
| 		&:first-child { | 		&:first-child { | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue