Skip to content

Commit 74238e1

Browse files
authored
feat: pinned channels (#158)
1 parent 7c8074e commit 74238e1

File tree

11 files changed

+337
-113
lines changed

11 files changed

+337
-113
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
},
2626
"dependencies": {
2727
"@better-fetch/fetch": "^1.1.18",
28+
"@dnd-kit-svelte/svelte": "^0.1.5",
29+
"@dnd-kit/abstract": "^0.1.21",
30+
"@dnd-kit/collision": "^0.1.21",
31+
"@dnd-kit/helpers": "^0.1.21",
2832
"@tailwindcss/vite": "^4.1.17",
2933
"@tauri-apps/api": "^2.9.1",
3034
"@tauri-apps/plugin-clipboard-manager": "~2.3.2",

pnpm-lock.yaml

Lines changed: 83 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 59 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
<script lang="ts">
2+
import { DragDropProvider } from "@dnd-kit-svelte/svelte";
3+
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers";
4+
import { move } from "@dnd-kit/helpers";
25
import { onDestroy } from "svelte";
36
import { flip } from "svelte/animate";
4-
import Users from "~icons/ph/users-bold";
57
import { app } from "$lib/app.svelte";
68
import type { Channel } from "$lib/models/channel.svelte";
9+
import { settings } from "$lib/settings";
10+
import Draggable from "./Draggable.svelte";
11+
import Droppable from "./Droppable.svelte";
12+
import { getSidebarContext } from "./Sidebar.svelte";
713
import StreamTooltip from "./StreamTooltip.svelte";
814
import { Separator } from "./ui/separator";
915
10-
const { collapsed = false } = $props();
16+
const sidebar = getSidebarContext();
1117
1218
const sorted = $derived(
1319
app.channels
@@ -26,22 +32,35 @@
2632
);
2733
2834
const groups = $derived.by(() => {
29-
const ephemeral = { type: "Ephemeral", channels: sorted.filter((c) => c.ephemeral) };
35+
const pinnedChannels = sorted
36+
.filter((c) => c.pinned)
37+
.sort((a, b) => {
38+
const indexA = settings.state.pinned.indexOf(a.id);
39+
const indexB = settings.state.pinned.indexOf(b.id);
40+
41+
return indexA - indexB;
42+
});
3043
44+
const pinned = { type: "Pinned", channels: pinnedChannels };
45+
const ephemeral = { type: "Ephemeral", channels: [] as Channel[] };
3146
const online = { type: "Online", channels: [] as Channel[] };
3247
const offline = { type: "Offline", channels: [] as Channel[] };
3348
3449
for (const channel of sorted) {
35-
if (channel.id === app.user?.id || channel.ephemeral) continue;
50+
if (channel.id === app.user?.id || channel.pinned) {
51+
continue;
52+
}
3653
37-
if (channel.stream) {
54+
if (channel.ephemeral) {
55+
ephemeral.channels.push(channel);
56+
} else if (channel.stream) {
3857
online.channels.push(channel);
3958
} else {
4059
offline.channels.push(channel);
4160
}
4261
}
4362
44-
return [ephemeral, online, offline].filter((g) => g.channels.length);
63+
return [pinned, ephemeral, online, offline].filter((g) => g.channels.length);
4564
});
4665
4766
const interval = setInterval(
@@ -58,61 +77,40 @@
5877
);
5978
6079
onDestroy(() => clearInterval(interval));
61-
62-
function formatViewers(viewers: number) {
63-
if (viewers >= 1000) {
64-
return `${(viewers / 1000).toFixed(1)}K`;
65-
}
66-
67-
return viewers.toString();
68-
}
6980
</script>
7081

71-
{#each groups as group}
72-
{#if collapsed}
73-
<Separator />
74-
{:else}
75-
<span class="text-muted-foreground mt-2 inline-block px-2 text-xs font-semibold uppercase">
76-
{group.type}
77-
</span>
78-
{/if}
79-
80-
{#each group.channels as channel (channel.user.id)}
81-
<div class="px-1.5" animate:flip={{ duration: 500 }}>
82-
<StreamTooltip {channel} {collapsed}>
83-
{#if !collapsed}
84-
{#if channel.stream}
85-
<div class="min-w-0 flex-1">
86-
<div class="flex items-center justify-between">
87-
<div
88-
class="text-sidebar-foreground flex items-center gap-x-1 truncate text-sm font-medium"
89-
>
90-
{channel.user.displayName}
91-
92-
{#if channel.stream.guests.size}
93-
<span class="text-xs">+{channel.stream.guests.size}</span>
94-
{/if}
95-
</div>
96-
97-
<div
98-
class="flex items-center gap-1 text-xs font-medium text-red-400"
99-
>
100-
<Users />
101-
{formatViewers(channel.stream.viewers)}
102-
</div>
103-
</div>
104-
105-
<p class="text-muted-foreground truncate text-xs">
106-
{channel.stream.game}
107-
</p>
108-
</div>
109-
{:else}
110-
<span class="text-muted-foreground truncate text-sm font-medium">
111-
{channel.user.displayName}
112-
</span>
113-
{/if}
114-
{/if}
115-
</StreamTooltip>
116-
</div>
82+
<DragDropProvider
83+
modifiers={[
84+
// @ts-expect-error - type mismatch
85+
RestrictToVerticalAxis,
86+
]}
87+
onDragOver={(event) => {
88+
settings.state.pinned = move(settings.state.pinned, event);
89+
}}
90+
>
91+
{#each groups as group}
92+
{#if sidebar.collapsed}
93+
<Separator />
94+
{:else}
95+
<span
96+
class="text-muted-foreground mt-2 inline-block px-2 text-xs font-semibold uppercase"
97+
>
98+
{group.type}
99+
</span>
100+
{/if}
101+
102+
{#if group.type === "Pinned"}
103+
<Droppable id="pinned-channels" class="space-y-1.5">
104+
{#each group.channels as channel, i (channel.user.id)}
105+
<Draggable id={channel.id} index={i} {channel} />
106+
{/each}
107+
</Droppable>
108+
{:else}
109+
{#each group.channels as channel (channel.user.id)}
110+
<div class="px-1.5" animate:flip={{ duration: 500 }}>
111+
<StreamTooltip {channel} />
112+
</div>
113+
{/each}
114+
{/if}
117115
{/each}
118-
{/each}
116+
</DragDropProvider>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<script lang="ts" module>
2+
export interface ChannelListItemProps {
3+
channel: Channel;
4+
}
5+
</script>
6+
7+
<script lang="ts">
8+
import DotsThreeCircle from "~icons/ph/dots-three-circle";
9+
import Users from "~icons/ph/users-bold";
10+
import type { Channel } from "$lib/models/channel.svelte";
11+
import { getSidebarContext } from "./Sidebar.svelte";
12+
13+
const sidebar = getSidebarContext();
14+
15+
const { channel }: ChannelListItemProps = $props();
16+
17+
function formatViewers(viewers: number) {
18+
if (viewers >= 1000) {
19+
return `${(viewers / 1000).toFixed(1)}K`;
20+
}
21+
22+
return viewers.toString();
23+
}
24+
</script>
25+
26+
<img
27+
class={["size-8 rounded-full object-cover", !channel.stream && "grayscale"]}
28+
src={channel.user.avatarUrl}
29+
alt={channel.user.displayName}
30+
width="150"
31+
height="150"
32+
draggable="false"
33+
/>
34+
35+
{#if sidebar.collapsed && channel.stream?.guests.size}
36+
<div
37+
class="bg-muted/70 absolute right-1 bottom-1 flex items-center justify-center rounded-full"
38+
>
39+
<DotsThreeCircle class="size-5" />
40+
</div>
41+
{/if}
42+
43+
{#if !sidebar.collapsed}
44+
{#if channel.stream}
45+
<div class="min-w-0 flex-1">
46+
<div class="flex items-center justify-between">
47+
<div
48+
class="text-sidebar-foreground flex items-center gap-x-1 truncate text-sm font-medium"
49+
>
50+
{channel.user.displayName}
51+
52+
{#if channel.stream.guests.size}
53+
<span class="text-xs">+{channel.stream.guests.size}</span>
54+
{/if}
55+
</div>
56+
57+
<div class="flex items-center gap-1 text-xs font-medium text-red-400">
58+
<Users />
59+
{formatViewers(channel.stream.viewers)}
60+
</div>
61+
</div>
62+
63+
<p class="text-muted-foreground truncate text-xs">
64+
{channel.stream.game}
65+
</p>
66+
</div>
67+
{:else}
68+
<span class="text-muted-foreground truncate text-sm font-medium">
69+
{channel.user.displayName}
70+
</span>
71+
{/if}
72+
{/if}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<script lang="ts">
2+
import { useSortable } from "@dnd-kit-svelte/svelte/sortable";
3+
import type { UseSortableInput } from "@dnd-kit-svelte/svelte/sortable";
4+
import ChannelListItem from "./ChannelListItem.svelte";
5+
import StreamTooltip from "./StreamTooltip.svelte";
6+
import type { ChannelListItemProps } from "./ChannelListItem.svelte";
7+
8+
const { channel, ...rest }: UseSortableInput & ChannelListItemProps = $props();
9+
10+
const { ref, isDragging } = useSortable(rest);
11+
</script>
12+
13+
<div class="relative px-1.5" {@attach ref}>
14+
<div class={[isDragging.current && "invisible"]}>
15+
<StreamTooltip {channel} />
16+
</div>
17+
18+
{#if isDragging.current}
19+
<div class="absolute inset-1.5 flex items-center gap-2 px-1.5 opacity-70">
20+
<ChannelListItem {channel} />
21+
</div>
22+
{/if}
23+
</div>

0 commit comments

Comments
 (0)