mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(manacore/web): workbench with app pages carousel on home
Replace the static app-grid home page with a workbench carousel where each app renders as a paper-sheet page that can be minimized, maximized, reordered, and resized: - AppPage.svelte: paper-sheet shell with lazy-loaded app modules - AppPagePicker.svelte: picker showing all 23 available apps - App component registry: maps appId to dynamic AppView imports - Horizontal fokus-track carousel with snap-scroll - Edit FAB with width pills (S/M/L/XL) - Minimized tabs bar for collapsed apps - Drag-and-drop reordering + arrow buttons in edit mode - Workbench state persisted to IndexedDB via shared-stores - Default layout: Todo + Calendar + Contacts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3d124f04a4
commit
f2af192172
4 changed files with 1249 additions and 428 deletions
|
|
@ -0,0 +1,357 @@
|
|||
<!--
|
||||
AppPage — Paper-sheet wrapper for any app in the workbench carousel.
|
||||
Lazy-loads the app's AppView component and renders it inside the page shell.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
X,
|
||||
Minus,
|
||||
DotsSixVertical,
|
||||
CornersOut,
|
||||
CornersIn,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
SpinnerGap,
|
||||
} from '@manacore/shared-icons';
|
||||
import { getAppEntry } from './app-registry';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
appId: string;
|
||||
pageWidth: string;
|
||||
maximized?: boolean;
|
||||
editMode?: boolean;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
onClose: () => void;
|
||||
onMinimize?: () => void;
|
||||
onMaximize?: () => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
appId,
|
||||
pageWidth,
|
||||
maximized = false,
|
||||
editMode = false,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
onClose,
|
||||
onMinimize,
|
||||
onMaximize,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
}: Props = $props();
|
||||
|
||||
let appEntry = $derived(getAppEntry(appId));
|
||||
let appName = $derived(appEntry?.name ?? appId);
|
||||
let appColor = $derived(appEntry?.color ?? '#6B7280');
|
||||
|
||||
// Lazy-load app component
|
||||
let AppComponent = $state<Component | null>(null);
|
||||
let loadError = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
AppComponent = null;
|
||||
loadError = false;
|
||||
if (appEntry) {
|
||||
appEntry.load().then(
|
||||
(mod) => (AppComponent = mod.default),
|
||||
() => (loadError = true)
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="app-page"
|
||||
class:maximized
|
||||
class:editing={editMode}
|
||||
style="width: {maximized ? '100%' : pageWidth}"
|
||||
>
|
||||
<div class="drag-handle-bar">
|
||||
<span class="drag-handle"><DotsSixVertical size={14} /></span>
|
||||
</div>
|
||||
|
||||
<!-- Edit controls -->
|
||||
{#if editMode}
|
||||
<div class="edit-controls">
|
||||
<div class="move-btns">
|
||||
{#if !isFirst && onMoveLeft}
|
||||
<button class="edit-btn" onclick={onMoveLeft} title="Nach links">
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isLast && onMoveRight}
|
||||
<button class="edit-btn" onclick={onMoveRight} title="Nach rechts">
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="edit-btn delete-btn" onclick={onClose} title="App entfernen">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<span class="app-dot" style="background-color: {appColor}"></span>
|
||||
<span class="app-name">{appName}</span>
|
||||
</div>
|
||||
{#if !editMode}
|
||||
<div class="header-actions">
|
||||
{#if onMinimize}
|
||||
<button class="header-btn" onclick={onMinimize} title="Minimieren">
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if onMaximize}
|
||||
<button
|
||||
class="header-btn"
|
||||
onclick={onMaximize}
|
||||
title={maximized ? 'Verkleinern' : 'Maximieren'}
|
||||
>
|
||||
{#if maximized}<CornersIn size={14} />{:else}<CornersOut size={14} />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="header-btn" onclick={onClose} title="Schließen">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- App content -->
|
||||
<div class="page-body">
|
||||
{#if loadError}
|
||||
<div class="load-state">
|
||||
<p>App konnte nicht geladen werden</p>
|
||||
</div>
|
||||
{:else if AppComponent}
|
||||
<AppComponent />
|
||||
{:else}
|
||||
<div class="load-state">
|
||||
<SpinnerGap size={24} class="spinner" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-page {
|
||||
flex: 0 0 auto;
|
||||
min-height: 60vh;
|
||||
background: #fffef5;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.dark) .app-page {
|
||||
background-color: #252220;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.app-page.editing {
|
||||
box-shadow:
|
||||
0 2px 12px rgba(139, 92, 246, 0.12),
|
||||
0 0 0 2px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
:global(.dark) .app-page.editing {
|
||||
box-shadow:
|
||||
0 2px 12px rgba(139, 92, 246, 0.2),
|
||||
0 0 0 2px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
.app-page.maximized {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
width: 100% !important;
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
animation: fadeInScale 0.2s ease-out;
|
||||
}
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0 0;
|
||||
}
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 14px;
|
||||
color: #d1d5db;
|
||||
cursor: grab;
|
||||
border-radius: 0.25rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.drag-handle:hover {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
:global(.dark) .drag-handle {
|
||||
color: #3f3b38;
|
||||
}
|
||||
:global(.dark) .drag-handle:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Edit controls */
|
||||
.edit-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
:global(.dark) .edit-controls {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.move-btns {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.edit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.edit-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .edit-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.app-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .app-name {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.header-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .header-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.page-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.load-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.load-state :global(.spinner) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
<!--
|
||||
AppPagePicker — Shows available apps to add as pages to the workbench.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { X } from '@manacore/shared-icons';
|
||||
import { APP_REGISTRY } from './app-registry';
|
||||
|
||||
interface Props {
|
||||
onSelect: (appId: string) => void;
|
||||
onClose: () => void;
|
||||
activeAppIds?: string[];
|
||||
}
|
||||
|
||||
let { onSelect, onClose, activeAppIds = [] }: Props = $props();
|
||||
|
||||
let availableApps = $derived(APP_REGISTRY.filter((app) => !activeAppIds.includes(app.id)));
|
||||
</script>
|
||||
|
||||
<div class="app-picker">
|
||||
<div class="picker-header">
|
||||
<h3 class="picker-title">App hinzufügen</h3>
|
||||
<button class="close-btn" onclick={onClose} title="Schließen"><X size={16} /></button>
|
||||
</div>
|
||||
<div class="picker-list">
|
||||
{#each availableApps as app, i (app.id)}
|
||||
{#if i > 0}<div class="divider"></div>{/if}
|
||||
<button class="app-option" onclick={() => onSelect(app.id)}>
|
||||
<div class="app-dot" style="background-color: {app.color}"></div>
|
||||
<span class="app-name">{app.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if availableApps.length === 0}
|
||||
<div class="empty-state"><p>Alle Apps sind bereits geöffnet</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-picker {
|
||||
flex: 0 0 auto;
|
||||
width: min(300px, 85vw);
|
||||
min-height: 60vh;
|
||||
max-height: 80vh;
|
||||
background: #fffef5;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.25s ease-out;
|
||||
}
|
||||
:global(.dark) .app-picker {
|
||||
background-color: #252220;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.picker-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
}
|
||||
:global(.dark) .picker-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.close-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.5rem 0.75rem;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
:global(.dark) .divider {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.app-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
.app-option:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
:global(.dark) .app-option:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.app-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .app-name {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* App Component Registry — Maps app IDs to lazy-loaded AppView components.
|
||||
*
|
||||
* Each entry provides the dynamic import for embedding in the workbench carousel.
|
||||
*/
|
||||
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export interface AppEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
load: () => Promise<{ default: Component }>;
|
||||
}
|
||||
|
||||
export const APP_REGISTRY: AppEntry[] = [
|
||||
{
|
||||
id: 'todo',
|
||||
name: 'Todo',
|
||||
color: '#8B5CF6',
|
||||
load: () => import('$lib/modules/todo/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
name: 'Kalender',
|
||||
color: '#3B82F6',
|
||||
load: () => import('$lib/modules/calendar/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'contacts',
|
||||
name: 'Kontakte',
|
||||
color: '#22C55E',
|
||||
load: () => import('$lib/modules/contacts/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
name: 'Chat',
|
||||
color: '#6366F1',
|
||||
load: () => import('$lib/modules/chat/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'times',
|
||||
name: 'Times',
|
||||
color: '#F59E0B',
|
||||
load: () => import('$lib/modules/times/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'zitare',
|
||||
name: 'Zitare',
|
||||
color: '#EC4899',
|
||||
load: () => import('$lib/modules/zitare/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'cards',
|
||||
name: 'Cards',
|
||||
color: '#EF4444',
|
||||
load: () => import('$lib/modules/cards/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'picture',
|
||||
name: 'Picture',
|
||||
color: '#8B5CF6',
|
||||
load: () => import('$lib/modules/picture/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'mukke',
|
||||
name: 'Mukke',
|
||||
color: '#F97316',
|
||||
load: () => import('$lib/modules/mukke/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'photos',
|
||||
name: 'Photos',
|
||||
color: '#06B6D4',
|
||||
load: () => import('$lib/modules/photos/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
name: 'Storage',
|
||||
color: '#6B7280',
|
||||
load: () => import('$lib/modules/storage/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'nutriphi',
|
||||
name: 'Nutriphi',
|
||||
color: '#22C55E',
|
||||
load: () => import('$lib/modules/nutriphi/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'planta',
|
||||
name: 'Planta',
|
||||
color: '#16A34A',
|
||||
load: () => import('$lib/modules/planta/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'presi',
|
||||
name: 'Presi',
|
||||
color: '#A855F7',
|
||||
load: () => import('$lib/modules/presi/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'inventar',
|
||||
name: 'Inventar',
|
||||
color: '#78716C',
|
||||
load: () => import('$lib/modules/inventar/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'memoro',
|
||||
name: 'Memoro',
|
||||
color: '#F59E0B',
|
||||
load: () => import('$lib/modules/memoro/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'questions',
|
||||
name: 'Questions',
|
||||
color: '#2563EB',
|
||||
load: () => import('$lib/modules/questions/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'skilltree',
|
||||
name: 'SkillTree',
|
||||
color: '#D946EF',
|
||||
load: () => import('$lib/modules/skilltree/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'moodlit',
|
||||
name: 'Moodlit',
|
||||
color: '#F97316',
|
||||
load: () => import('$lib/modules/moodlit/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'citycorners',
|
||||
name: 'CityCorners',
|
||||
color: '#14B8A6',
|
||||
load: () => import('$lib/modules/citycorners/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'uload',
|
||||
name: 'uLoad',
|
||||
color: '#0EA5E9',
|
||||
load: () => import('$lib/modules/uload/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'calc',
|
||||
name: 'Calc',
|
||||
color: '#6B7280',
|
||||
load: () => import('$lib/modules/calc/AppView.svelte'),
|
||||
},
|
||||
{
|
||||
id: 'playground',
|
||||
name: 'Playground',
|
||||
color: '#9CA3AF',
|
||||
load: () => import('$lib/modules/playground/AppView.svelte'),
|
||||
},
|
||||
];
|
||||
|
||||
export function getAppEntry(appId: string): AppEntry | undefined {
|
||||
return APP_REGISTRY.find((a) => a.id === appId);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue