diff --git a/webapp/src/app/core/workspace/workspace-add-button/workspace-add-button.component.ts b/webapp/src/app/core/workspace/workspace-add-button/workspace-add-button.component.ts new file mode 100644 index 00000000..cf79e9a9 --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-add-button/workspace-add-button.component.ts @@ -0,0 +1,26 @@ +import { Component, computed, input } from '@angular/core'; +import { HlmButtonDirective } from '@spartan-ng/ui-button-helm'; +import { hlm } from '@spartan-ng/ui-core'; +import { LucideAngularModule, CirclePlus, Plus } from 'lucide-angular'; + +@Component({ + selector: 'app-workspace-add-button', + standalone: true, + imports: [HlmButtonDirective, LucideAngularModule], + template: ` + + ` +}) +export class WorkspaceAddButtonComponent { + protected CirclePlus = CirclePlus; + protected Plus = Plus; + + isCompact = input.required(); + + computedClass = computed(() => hlm(this.isCompact() ? '' : 'flex gap-2 w-full justify-start')); +} diff --git a/webapp/src/app/core/workspace/workspace-add-button/workspace-add-button.stories.ts b/webapp/src/app/core/workspace/workspace-add-button/workspace-add-button.stories.ts new file mode 100644 index 00000000..19db74ac --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-add-button/workspace-add-button.stories.ts @@ -0,0 +1,27 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { WorkspaceAddButtonComponent } from './workspace-add-button.component'; + +const meta: Meta = { + component: WorkspaceAddButtonComponent, + tags: ['autodocs'], + args: { + isCompact: false + }, + argTypes: { + isCompact: { + control: { type: 'boolean' } + } + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const IsCompact: Story = { + args: { + isCompact: true + } +}; diff --git a/webapp/src/app/core/workspace/workspace-badge/workspace-badge.component.html b/webapp/src/app/core/workspace/workspace-badge/workspace-badge.component.html new file mode 100644 index 00000000..6a199d4b --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-badge/workspace-badge.component.html @@ -0,0 +1,30 @@ +
+
+ + + + +

Workspaces

+
+ +
+ +
+
+ + + + +
+
+
+ + {{ selectedWorkspace().title }} + +
diff --git a/webapp/src/app/core/workspace/workspace-badge/workspace-badge.component.ts b/webapp/src/app/core/workspace/workspace-badge/workspace-badge.component.ts new file mode 100644 index 00000000..57936cac --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-badge/workspace-badge.component.ts @@ -0,0 +1,40 @@ +import { Component, input, output } from '@angular/core'; +import { BrnSheetContentDirective } from '@spartan-ng/ui-sheet-brain'; +import { HlmSheetComponent, HlmSheetContentComponent, HlmSheetFooterComponent, HlmSheetHeaderComponent, HlmSheetTitleDirective } from '@spartan-ng/ui-sheet-helm'; +import { HlmMenuSeparatorComponent } from '@spartan-ng/ui-menu-helm'; +import { HlmScrollAreaComponent } from '@spartan-ng/ui-scrollarea-helm'; +import { WorkspaceThumbComponent } from '../workspace-thumb/workspace-thumb.component'; +import { WorkspaceOptionSelectorComponent } from '../workspace-option-selector/workspace-option-selector.component'; +import { WorkspaceAddButtonComponent } from '../workspace-add-button/workspace-add-button.component'; + +type Workspace = { + id: string; + title: string; + iconUrl: string; +}; + +@Component({ + selector: 'app-workspace-badge', + standalone: true, + imports: [ + BrnSheetContentDirective, + HlmSheetComponent, + HlmSheetContentComponent, + HlmSheetHeaderComponent, + HlmSheetFooterComponent, + HlmSheetTitleDirective, + HlmScrollAreaComponent, + HlmMenuSeparatorComponent, + WorkspaceThumbComponent, + WorkspaceOptionSelectorComponent, + WorkspaceAddButtonComponent + ], + templateUrl: './workspace-badge.component.html' +}) +export class WorkspaceBadgeComponent { + selectedWorkspace = input.required(); + workspaces = input.required(); + + onSelect = output(); + onSignOut = output(); +} diff --git a/webapp/src/app/core/workspace/workspace-badge/workspace-badge.stories.ts b/webapp/src/app/core/workspace/workspace-badge/workspace-badge.stories.ts new file mode 100644 index 00000000..7b49e7a6 --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-badge/workspace-badge.stories.ts @@ -0,0 +1,60 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { WorkspaceBadgeComponent } from './workspace-badge.component'; +import { fn } from '@storybook/test'; + +const workspaces = [ + { + id: '1', + title: 'AET TUM', + iconUrl: 'https://avatars.githubusercontent.com/u/11064260?s=48&v=4' + }, + { + id: '2', + title: 'Hephaestus', + iconUrl: '' + }, + { + id: '3', + title: 'Intro Course', + iconUrl: 'https://avatars.githubusercontent.com/u/11064260?s=48&v=4' + } +]; + +const meta: Meta = { + component: WorkspaceBadgeComponent, + tags: ['autodocs'], + args: { + selectedWorkspace: workspaces[0], + workspaces, + onSelect: fn(), + onSignOut: fn() + }, + argTypes: { + selectedWorkspace: { + control: { type: 'object' } + }, + workspaces: { + control: { type: 'object' } + } + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + parameters: { + viewport: { + defaultViewport: 'responsive' + } + } +}; + +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile1' + } + } +}; diff --git a/webapp/src/app/core/workspace/workspace-option-selector/workspace-option-selector.component.ts b/webapp/src/app/core/workspace/workspace-option-selector/workspace-option-selector.component.ts new file mode 100644 index 00000000..1962e7bd --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-option-selector/workspace-option-selector.component.ts @@ -0,0 +1,39 @@ +import { Component, computed, input, output } from '@angular/core'; +import { WorkspaceOptionComponent } from '../workspace-option/workspace-option.component'; +import { hlm } from '@spartan-ng/ui-core'; + +type Workspace = { + id: string; + title: string; + iconUrl: string; +}; + +@Component({ + selector: 'app-workspace-option-selector', + standalone: true, + imports: [WorkspaceOptionComponent], + template: ` +
+ @for (workspace of workspaces(); track workspace.id) { + + } +
+ ` +}) +export class WorkspaceOptionSelectorComponent { + isCompact = input.required(); + selectedWorkspace = input.required(); + workspaces = input.required(); + + onSelect = output(); + onSignOut = output(); + + computedClass = computed(() => hlm('flex flex-col', this.isCompact() ? 'gap-3' : 'gap-1')); +} diff --git a/webapp/src/app/core/workspace/workspace-option-selector/workspace-option-selector.stories.ts b/webapp/src/app/core/workspace/workspace-option-selector/workspace-option-selector.stories.ts new file mode 100644 index 00000000..c7046eb7 --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-option-selector/workspace-option-selector.stories.ts @@ -0,0 +1,59 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { WorkspaceOptionSelectorComponent } from './workspace-option-selector.component'; +import { fn } from '@storybook/test'; + +const workspaces = [ + { + id: '1', + title: 'AET TUM', + iconUrl: 'https://avatars.githubusercontent.com/u/11064260?s=48&v=4' + }, + { + id: '2', + title: 'Hephaestus', + iconUrl: '' + }, + { + id: '3', + title: 'Intro Course', + iconUrl: 'https://avatars.githubusercontent.com/u/11064260?s=48&v=4' + } +]; + +const meta: Meta = { + component: WorkspaceOptionSelectorComponent, + tags: ['autodocs'], + args: { + isCompact: false, + selectedWorkspace: workspaces[0], + workspaces, + onSelect: fn(), + onSignOut: fn() + }, + argTypes: { + isCompact: { + control: { type: 'boolean' } + }, + selectedWorkspace: { + control: { type: 'object' } + }, + workspaces: { + control: { type: 'object' } + } + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const IsCompact: Story = { + args: { + isCompact: true + }, + parameters: { + layout: 'centered' + } +}; diff --git a/webapp/src/app/core/workspace/workspace-option/workspace-option.component.html b/webapp/src/app/core/workspace/workspace-option/workspace-option.component.html new file mode 100644 index 00000000..8e4b0b93 --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-option/workspace-option.component.html @@ -0,0 +1,37 @@ +@if (isCompact()) { + + + {{ title() }} + +} @else { +
+ + + {{ title() }} + + @if (isSelected()) { + + } +
+} + + + + + + + + diff --git a/webapp/src/app/core/workspace/workspace-option/workspace-option.component.ts b/webapp/src/app/core/workspace/workspace-option/workspace-option.component.ts new file mode 100644 index 00000000..dd242aef --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-option/workspace-option.component.ts @@ -0,0 +1,41 @@ +import { Component, computed, input, output } from '@angular/core'; +import { WorkspaceThumbComponent } from '../workspace-thumb/workspace-thumb.component'; +import { LucideAngularModule, Ellipsis, LogOut } from 'lucide-angular'; +import { hlm } from '@spartan-ng/ui-core'; +import { HlmTooltipComponent, HlmTooltipTriggerDirective } from '@spartan-ng/ui-tooltip-helm'; +import { BrnTooltipContentDirective } from '@spartan-ng/ui-tooltip-brain'; +import { BrnContextMenuTriggerDirective, BrnMenuTriggerDirective } from '@spartan-ng/ui-menu-brain'; +import { HlmMenuComponent, HlmMenuGroupComponent, HlmMenuItemDirective, HlmMenuItemIconDirective } from '@spartan-ng/ui-menu-helm'; + +@Component({ + selector: 'app-workspace-option', + standalone: true, + imports: [ + HlmTooltipComponent, + BrnContextMenuTriggerDirective, + HlmTooltipTriggerDirective, + BrnTooltipContentDirective, + BrnMenuTriggerDirective, + HlmMenuComponent, + HlmMenuItemDirective, + HlmMenuItemIconDirective, + HlmMenuGroupComponent, + WorkspaceThumbComponent, + LucideAngularModule + ], + templateUrl: './workspace-option.component.html' +}) +export class WorkspaceOptionComponent { + protected Ellipsis = Ellipsis; + protected LogOut = LogOut; + + isCompact = input.required(); + isSelected = input(); + iconUrl = input(); + title = input.required(); + + onSelect = output(); + onSignOut = output(); + + computedClass = computed(() => hlm('flex items-center gap-2 hover:bg-accent/50 p-3 rounded-xl cursor-pointer duration-300 transition-all', this.isSelected() && 'bg-accent')); +} diff --git a/webapp/src/app/core/workspace/workspace-option/workspace-option.stories.ts b/webapp/src/app/core/workspace/workspace-option/workspace-option.stories.ts new file mode 100644 index 00000000..2e49264b --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-option/workspace-option.stories.ts @@ -0,0 +1,37 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { WorkspaceOptionComponent } from './workspace-option.component'; +import { fn } from '@storybook/test'; + +const meta: Meta = { + component: WorkspaceOptionComponent, + tags: ['autodocs'], + args: { + isCompact: false, + isSelected: false, + iconUrl: 'https://avatars.githubusercontent.com/u/11064260?s=48&v=4', + title: 'AET TUM', + onSelect: fn(), + onSignOut: fn() + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const IsSelected: Story = { + args: { + isSelected: true + } +}; + +export const IsCompact: Story = { + args: { + isCompact: true + }, + parameters: { + layout: 'centered' + } +}; diff --git a/webapp/src/app/core/workspace/workspace-thumb/workspace-thumb.component.ts b/webapp/src/app/core/workspace/workspace-thumb/workspace-thumb.component.ts new file mode 100644 index 00000000..7622769b --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-thumb/workspace-thumb.component.ts @@ -0,0 +1,41 @@ +import { Component, computed, input, output } from '@angular/core'; +import { LucideAngularModule, Hammer } from 'lucide-angular'; +import { HlmAvatarModule } from '@spartan-ng/ui-avatar-helm'; +import { BrnSheetTriggerDirective } from '@spartan-ng/ui-sheet-brain'; +import { hlm } from '@spartan-ng/ui-core'; + +@Component({ + selector: 'app-workspace-thumb', + standalone: true, + imports: [BrnSheetTriggerDirective, HlmAvatarModule, LucideAngularModule], + template: ` + + ` +}) +export class WorkspaceThumbComponent { + protected Hammer = Hammer; + + variant = input<'small' | 'base' | 'medium'>('base'); + isSelected = input(); + hoverRingEnabled = input(true); + iconUrl = input(); + onClick = output(); + + computedClass = computed(() => { + return hlm( + 'block rounded-md ring-offset-background ring-offset-2 duration-200 transition-all', + this.isSelected() ? 'ring-2 ring-primary' : this.hoverRingEnabled() && 'hover:ring-2 hover:ring-muted-foreground/50' + ); + }); + + handleClick(event: MouseEvent) { + this.onClick.emit(event); + } +} diff --git a/webapp/src/app/core/workspace/workspace-thumb/workspace-thumb.stories.ts b/webapp/src/app/core/workspace/workspace-thumb/workspace-thumb.stories.ts new file mode 100644 index 00000000..69cc14af --- /dev/null +++ b/webapp/src/app/core/workspace/workspace-thumb/workspace-thumb.stories.ts @@ -0,0 +1,49 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { WorkspaceThumbComponent } from './workspace-thumb.component'; +import { fn } from '@storybook/test'; + +const meta: Meta = { + component: WorkspaceThumbComponent, + tags: ['autodocs'], + args: { + isSelected: false, + hoverRingEnabled: true, + iconUrl: 'https://avatars.githubusercontent.com/u/11064260?s=48&v=4', + onClick: fn() + }, + argTypes: { + isSelected: { + control: { type: 'boolean' } + }, + hoverRingEnabled: { + control: { type: 'boolean' } + }, + iconUrl: { + control: { type: 'text' } + } + } +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Selected: Story = { + args: { + isSelected: true + } +}; + +export const RingDisabled: Story = { + args: { + hoverRingEnabled: false + } +}; + +export const NoIcon: Story = { + args: { + iconUrl: '' + } +}; diff --git a/webapp/src/libs/ui/ui-avatar-helm/src/lib/fallback/hlm-avatar-fallback.directive.ts b/webapp/src/libs/ui/ui-avatar-helm/src/lib/fallback/hlm-avatar-fallback.directive.ts index 0c2b8136..a56620c8 100644 --- a/webapp/src/libs/ui/ui-avatar-helm/src/lib/fallback/hlm-avatar-fallback.directive.ts +++ b/webapp/src/libs/ui/ui-avatar-helm/src/lib/fallback/hlm-avatar-fallback.directive.ts @@ -31,6 +31,6 @@ export class HlmAvatarFallbackDirective { }); protected readonly _computedClass = computed(() => { - return hlm('flex h-full w-full items-center justify-center rounded-full', this._autoColorTextCls() ?? 'bg-muted', this._brn?.userCls()); + return hlm('flex h-full w-full items-center justify-center', this._autoColorTextCls() ?? 'bg-muted', this._brn?.userCls()); }); } diff --git a/webapp/src/libs/ui/ui-avatar-helm/src/lib/hlm-avatar.component.ts b/webapp/src/libs/ui/ui-avatar-helm/src/lib/hlm-avatar.component.ts index afefdb0d..dac6d6e2 100644 --- a/webapp/src/libs/ui/ui-avatar-helm/src/lib/hlm-avatar.component.ts +++ b/webapp/src/libs/ui/ui-avatar-helm/src/lib/hlm-avatar.component.ts @@ -4,17 +4,23 @@ import { hlm } from '@spartan-ng/ui-core'; import { type VariantProps, cva } from 'class-variance-authority'; import type { ClassValue } from 'clsx'; -export const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full', { +export const avatarVariants = cva('relative flex shrink-0 overflow-hidden', { variants: { variant: { small: 'h-6 w-6 text-xs', + base: 'h-7 w-7 text-sm', medium: 'h-10 w-10', large: 'h-14 w-14 text-lg', extralarge: 'h-32 w-32 lg:h-40 lg:w-40 text-xl md:text-3xl' + }, + shape: { + circle: 'rounded-full', + square: 'rounded-md' } }, defaultVariants: { - variant: 'medium' + variant: 'medium', + shape: 'circle' } }); @@ -38,11 +44,18 @@ type AvatarVariants = VariantProps; }) export class HlmAvatarComponent extends BrnAvatarComponent { public readonly userClass = input('', { alias: 'class' }); - protected readonly _computedClass = computed(() => hlm(avatarVariants({ variant: this._variant() }), this.userClass())); + protected readonly _computedClass = computed(() => hlm(avatarVariants({ variant: this._variant(), shape: this._shape() }), this.userClass())); private readonly _variant = signal('medium'); + private readonly _shape = signal('circle'); + @Input() set variant(variant: AvatarVariants['variant']) { this._variant.set(variant); } + + @Input() + set shape(shape: AvatarVariants['shape']) { + this._shape.set(shape); + } }