Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion create-a-container/client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Input, ServiceBadge, useToast } from '@mieweb/ui';
import { UserPlus } from 'lucide-react';
import { ApiError } from '@/lib/api';
import { keys, queries } from '@/lib/queries';

/**
* Presentational list of collaborator usernames rendered as removable chips.
* Shared by the live manager and the create-form's collaborators field so
* both surfaces look identical.
*/
export function CollaboratorChips({
usernames,
onRemove,
disabled,
emptyText = 'Not shared with anyone yet.',
}: {
usernames: string[];
onRemove: (username: string) => void;
disabled?: boolean;
emptyText?: string;
}) {
if (usernames.length === 0) {
return <p className="text-sm text-muted-foreground">{emptyText}</p>;
}
return (
<ul className="flex flex-wrap gap-2" aria-label="Shared with">
{usernames.map((username) => (
<li key={username}>
<ServiceBadge
variant="secondary"
size="sm"
removable
onRemove={() => {
if (!disabled) onRemove(username);
}}
>
{username}
</ServiceBadge>
</li>
))}
</ul>
);
}

/**
* An accessible username input + add button. Surfaces a validation/error
* message (e.g. "user does not exist") inline beneath the field.
*/
export function AddCollaboratorField({
onAdd,
pending,
error,
label = 'Username',
}: {
onAdd: (username: string) => void;
pending?: boolean;
error?: string | null;
label?: string;
}) {
const [value, setValue] = useState('');

const submit = () => {
const username = value.trim();
if (!username) return;
onAdd(username);
setValue('');
};

return (
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label={label}
placeholder="Enter a username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
value={value}
error={error || undefined}
hasError={!!error}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
// Submit on Enter without bubbling to an enclosing form.
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
/>
</div>
<Button
type="button"
variant="outline"
leftIcon={<UserPlus className="size-4" />}
isLoading={pending}
disabled={!value.trim()}
onClick={submit}
>
Share
</Button>
</div>
);
}

/**
* Live sharing manager for an existing container: lists current collaborators
* and lets the owner/admin add or remove them via the API. Used both in the
* list page's share dialog and the edit form's Sharing section.
*/
export function CollaboratorsManager({
siteId,
containerId,
collaborators,
}: {
siteId: string;
containerId: number;
collaborators: string[];
}) {
const qc = useQueryClient();
const toast = useToast();
const [addError, setAddError] = useState<string | null>(null);

const invalidate = () => {
qc.invalidateQueries({ queryKey: keys.container(siteId, containerId) });
qc.invalidateQueries({ queryKey: keys.containers(siteId) });
};

const add = useMutation({
mutationFn: (username: string) => queries.shareContainer(siteId, containerId, username),
onSuccess: (_data, username) => {
setAddError(null);
toast.success(`Shared with ${username}`);
invalidate();
},
onError: (err: ApiError) => setAddError(err.message),
});

const remove = useMutation({
mutationFn: (username: string) => queries.unshareContainer(siteId, containerId, username),
onSuccess: (_data, username) => {
toast.success(`Stopped sharing with ${username}`);
invalidate();
},
onError: (err: ApiError) => toast.error(err.message),
});

return (
<div className="flex flex-col gap-4">
<CollaboratorChips
usernames={collaborators}
onRemove={(u) => remove.mutate(u)}
disabled={remove.isPending}
/>
<AddCollaboratorField
onAdd={(u) => add.mutate(u)}
pending={add.isPending}
error={addError}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { useState } from 'react';
import { Button, Dropdown, DropdownItem, Input } from '@mieweb/ui';
import { ChevronDown, Filter, User as UserIcon, X } from 'lucide-react';

export interface FilterOption {
value: string;
label: string;
}

/**
* A compact multiselect built on @mieweb/ui's Dropdown (multiSelect mode).
* Selected values are controlled by the parent so filter state stays in the URL.
*/
function MultiSelect({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has a "select all" option which I would prefer to the custom "Everone" option we have.

label,
emptyLabel,
icon,
options,
selected,
onChange,
searchable = false,
showSelectAll = false,
}: {
label: string;
/** Trigger summary shown when nothing is selected. */
emptyLabel: string;
icon?: React.ReactElement;
options: FilterOption[];
selected: string[];
onChange: (next: string[]) => void;
searchable?: boolean;
showSelectAll?: boolean;
}) {
const summary =
selected.length === 0
? emptyLabel
: selected.length === 1
? (options.find((o) => o.value === selected[0])?.label ?? selected[0])
: `${selected.length} selected`;

return (
<Dropdown
multiSelect
selectedValues={selected}
onSelectedValuesChange={onChange}
searchable={searchable}
searchPlaceholder="Search…"
searchAriaLabel={`Search ${label}`}
showSelectAll={showSelectAll}
trigger={
<Button
variant="secondary"
size="sm"
leftIcon={icon}
aria-label={`${label}: ${summary}`}
>
<span className="font-normal text-muted-foreground">{label}:</span>
<span className="ml-1">{summary}</span>
<ChevronDown className="ml-1 size-4" aria-hidden="true" />
</Button>
}
>
{options.map((o) => (
<DropdownItem key={o.value} value={o.value}>
{o.label}
</DropdownItem>
))}
</Dropdown>
);
}

export interface ContainerFiltersProps {
userOptions: FilterOption[];
selectedUsers: string[];
onUsersChange: (next: string[]) => void;
statusOptions: FilterOption[];
selectedStatuses: string[];
onStatusesChange: (next: string[]) => void;
templateOptions: FilterOption[];
selectedTemplates: string[];
onTemplatesChange: (next: string[]) => void;
hostname: string;
onHostnameChange: (next: string) => void;
onClearAll: () => void;
}

/**
* Filter bar for the containers list. "User" is always visible and drives the
* server query; the remaining filters (status, template, hostname) live behind
* a "More filters" disclosure and filter the loaded rows client-side.
*/
export function ContainerFilters({
userOptions,
selectedUsers,
onUsersChange,
statusOptions,
selectedStatuses,
onStatusesChange,
templateOptions,
selectedTemplates,
onTemplatesChange,
hostname,
onHostnameChange,
onClearAll,
}: ContainerFiltersProps) {
const [showMore, setShowMore] = useState(false);
const extraCount =
selectedStatuses.length + selectedTemplates.length + (hostname ? 1 : 0);
const activeCount = selectedUsers.length + extraCount;

return (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<MultiSelect
label="User"
emptyLabel="All"
icon={<UserIcon className="size-4" />}
options={userOptions}
selected={selectedUsers}
onChange={onUsersChange}
searchable
showSelectAll
/>
<Button
variant="ghost"
size="sm"
leftIcon={<Filter className="size-4" />}
aria-expanded={showMore}
onClick={() => setShowMore((s) => !s)}
>
More filters{extraCount > 0 ? ` (${extraCount})` : ''}
</Button>
{activeCount > 0 && (
<Button
variant="ghost"
size="sm"
leftIcon={<X className="size-4" />}
onClick={onClearAll}
className="ml-auto text-muted-foreground"
>
Clear
</Button>
)}
</div>
{showMore && (
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-2">
<MultiSelect
label="Status"
emptyLabel="Any"
options={statusOptions}
selected={selectedStatuses}
onChange={onStatusesChange}
/>
<MultiSelect
label="Template"
emptyLabel="Any"
options={templateOptions}
selected={selectedTemplates}
onChange={onTemplatesChange}
searchable
/>
<Input
value={hostname}
onChange={(e) => onHostnameChange(e.target.value)}
placeholder="Search hostname…"
aria-label="Search by hostname"
className="h-8 w-48"
/>
</div>
)}
</div>
);
}
31 changes: 31 additions & 0 deletions create-a-container/client/src/components/containers/HttpLinks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ExternalLink } from 'lucide-react';
import type { Container } from '@/lib/types';
import { linkClass } from './shared';

/** External links for a container's HTTP services, optionally capped at `limit`. */
export function HttpLinks({ c, limit }: { c: Container; limit?: number }) {
if (c.httpEntries.length === 0) return <span className="text-muted-foreground">—</span>;
const entries = limit ? c.httpEntries.slice(0, limit) : c.httpEntries;
return (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-0.5">
{entries.map((h) =>
h.externalUrl ? (
<a
key={`${c.id}-${h.port}`}
href={h.externalUrl}
target="_blank"
rel="noreferrer"
className={`inline-flex items-center gap-1 text-xs ${linkClass}`}
>
<ExternalLink className="size-3 shrink-0" aria-hidden="true" />
<span className="break-all">{h.externalUrl.replace(/^https?:\/\//, '')}</span>
</a>
) : (
<span key={`${c.id}-${h.port}`} className="text-xs">
:{h.port}
</span>
),
)}
</span>
);
}
13 changes: 13 additions & 0 deletions create-a-container/client/src/components/containers/Meta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react';

/** Inline labelled value used for container card metadata (Node, User, HTTP, …). */
export function Meta({ label, children }: { label: string; children: ReactNode }) {
return (
<span className="inline-flex items-baseline gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="text-xs">{children}</span>
</span>
);
}
Loading
Loading