Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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