From 2ca984d00b7859eba6f20dba60b328c7db31cc46 Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Sat, 18 Oct 2025 10:21:40 -0700 Subject: [PATCH 01/29] Can edit channel tags from settings page --- components/TagPicker.vue | 9 +++++++-- components/nav/VerticalIconNav.vue | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/components/TagPicker.vue b/components/TagPicker.vue index fa64f941..e402f08e 100644 --- a/components/TagPicker.vue +++ b/components/TagPicker.vue @@ -24,9 +24,14 @@ const emit = defineEmits(['setSelectedTags']); const searchQuery = ref(''); -const { mutate: createTag, loading: createTagLoading } = +const { mutate: createTag, loading: createTagLoading, onError: onCreateTagError } = useMutation(CREATE_TAG); +onCreateTagError((error) => { + console.error('Tag creation mutation error:', error); + alert('Failed to create tag: ' + error.message); +}); + const { loading: tagsLoading, result: tagsResult, @@ -39,7 +44,7 @@ const { }, })), { - fetchPolicy: 'cache-first', + fetchPolicy: 'cache-and-network', } ); diff --git a/components/nav/VerticalIconNav.vue b/components/nav/VerticalIconNav.vue index 93a43b1b..06235bbf 100644 --- a/components/nav/VerticalIconNav.vue +++ b/components/nav/VerticalIconNav.vue @@ -202,7 +202,7 @@ const navigateTo = async (route: RouteLocationAsRelativeGeneric) => { const getIconCircleClasses = (isActive: boolean) => { const baseClasses = - 'w-12 h-12 rounded-full bg-gray-800 hover:bg-gray-700 flex items-center justify-center transition-colors duration-200 cursor-pointer'; + 'w-10 h-10 rounded-full bg-gray-800 hover:bg-gray-700 flex items-center justify-center transition-colors duration-200 cursor-pointer'; return isActive ? `${baseClasses} ring-1 ring-orange-500 ring-offset-1 ring-offset-gray-900` : baseClasses; @@ -210,7 +210,7 @@ const getIconCircleClasses = (isActive: boolean) => { const getForumIconClasses = (isActive: boolean) => { const baseClasses = - 'w-12 h-12 rounded-full bg-gray-800 hover:bg-gray-700 flex items-center justify-center transition-colors duration-200 cursor-pointer overflow-hidden'; + 'w-10 h-10 rounded-full bg-gray-800 hover:bg-gray-700 flex items-center justify-center transition-colors duration-200 cursor-pointer overflow-hidden'; return isActive ? `${baseClasses} ring-2 ring-orange-500 ring-offset-0` : baseClasses; From f816f2222ea829d95f17ed3900f79fa20a46a4dc Mon Sep 17 00:00:00 2001 From: Catherine Luse Date: Sat, 18 Oct 2025 11:11:13 -0700 Subject: [PATCH 02/29] Add in-place tag editor to channel sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Allow channel admins to edit tags directly from sidebar - Check canUpdateChannel permission before showing edit button - Fix MultiSelect to preserve selected tags during search - Fix checkbox interaction to properly toggle tag selection - Fix TagPicker test to mock onError callback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/MultiSelect.vue | 16 +-- components/TagPicker.spec.ts | 1 + components/channel/ChannelSidebar.vue | 156 +++++++++++++++++++++++++- 3 files changed, 161 insertions(+), 12 deletions(-) diff --git a/components/MultiSelect.vue b/components/MultiSelect.vue index 303058d8..6f583a73 100644 --- a/components/MultiSelect.vue +++ b/components/MultiSelect.vue @@ -151,8 +151,12 @@ const filteredOptions = computed(() => { }); // Get option by value -const getOptionByValue = (value: any): MultiSelectOption | undefined => { - return props.options.find((option) => option.value === value); +const getOptionByValue = (value: any): MultiSelectOption => { + // Try to find in current options + const option = props.options.find((option) => option.value === value); + + // If not found (e.g., filtered out by search), return a simple fallback option + return option || { value, label: String(value) }; }; // Watch for external changes to modelValue @@ -164,11 +168,9 @@ watch( { deep: true } ); -// Selected options for display +// Selected options for display (independent of search filtering) const selectedOptions = computed(() => { - return selected.value - .map((value) => getOptionByValue(value)) - .filter(Boolean) as MultiSelectOption[]; + return selected.value.map((value) => getOptionByValue(value)); }); @@ -338,7 +340,7 @@ const selectedOptions = computed(() => { :checked="selected.includes(option.value)" :disabled="option.disabled" class="h-4 w-4 rounded border border-gray-400 text-orange-600 checked:border-orange-600 checked:bg-orange-600 checked:text-white focus:ring-orange-500 dark:border-gray-500 dark:bg-gray-700" - @click.stop + readonly > diff --git a/components/TagPicker.spec.ts b/components/TagPicker.spec.ts index 1a40c79d..10e52c27 100644 --- a/components/TagPicker.spec.ts +++ b/components/TagPicker.spec.ts @@ -13,6 +13,7 @@ vi.mock('@vue/apollo-composable', () => ({ useMutation: vi.fn(() => ({ mutate: vi.fn(), loading: ref(false), + onError: vi.fn(), })), })); diff --git a/components/channel/ChannelSidebar.vue b/components/channel/ChannelSidebar.vue index 79e75c0c..4a4edbff 100644 --- a/components/channel/ChannelSidebar.vue +++ b/components/channel/ChannelSidebar.vue @@ -2,7 +2,7 @@ import { computed, ref } from 'vue'; import type { PropType } from 'vue'; import Tag from '@/components/TagComponent.vue'; -import type { Channel } from '@/__generated__/graphql'; +import type { Channel, Tag as TagData } from '@/__generated__/graphql'; import ChannelRules from '@/components/channel/Rules.vue'; import SidebarEventList from '@/components/channel/SidebarEventList.vue'; import MarkdownPreview from '@/components/MarkdownPreview.vue'; @@ -10,7 +10,11 @@ import { useRouter, useRoute } from 'nuxt/app'; import FontSizeControl from '@/components/channel/FontSizeControl.vue'; import BecomeAdminModal from '@/components/channel/BecomeAdminModal.vue'; import AddToChannelFavorites from '@/components/favorites/AddToChannelFavorites.vue'; -import { isAuthenticatedVar } from '@/cache'; +import { isAuthenticatedVar, usernameVar, modProfileNameVar } from '@/cache'; +import { checkPermission } from '@/utils/permissionUtils'; +import { useMutation } from '@vue/apollo-composable'; +import { UPDATE_CHANNEL } from '@/graphQLData/channel/mutations'; +import TagPicker from '@/components/TagPicker.vue'; const props = defineProps({ channel: { @@ -64,6 +68,97 @@ const closeBecomeAdminModal = () => { const handleBecomeAdminSuccess = () => { emit('refetchChannelData'); }; + +// Tag editing functionality +const isEditingTags = ref(false); +const selectedTags = ref([]); + +// Check if user has permission to update channel +const canUpdateChannel = computed(() => { + if (!isAuthenticatedVar.value || !props.channel) { + return false; + } + + const username = usernameVar.value; + const modProfileName = modProfileNameVar.value; + + // Check if user is channel admin (bypass permission check) + const isChannelAdmin = props.channel.Admins?.some( + (admin) => admin.username === username + ); + + if (isChannelAdmin) { + return true; + } + + // Check canUpdateChannel permission from role + return checkPermission({ + permissionData: props.channel, + standardModRole: props.channel.DefaultChannelRole, + elevatedModRole: null, // User permissions don't use elevated mod role + username, + modProfileName, + action: 'canUpdateChannel', + }); +}); + +// Show tags section if there are tags OR if user can edit tags +const showTagsSection = computed(() => { + return (props.channel?.Tags?.length ?? 0) > 0 || canUpdateChannel.value; +}); + +const existingTags = computed(() => { + return props.channel?.Tags?.map((tag: TagData) => tag.text) || []; +}); + +const startEditingTags = () => { + selectedTags.value = [...existingTags.value]; + isEditingTags.value = true; +}; + +const cancelEditingTags = () => { + isEditingTags.value = false; + selectedTags.value = []; +}; + +const { + mutate: updateChannel, + loading: updateChannelLoading, + error: updateChannelError, + onDone: onUpdateChannelDone, +} = useMutation(UPDATE_CHANNEL); + +onUpdateChannelDone(() => { + isEditingTags.value = false; + selectedTags.value = []; + emit('refetchChannelData'); +}); + +const saveTags = () => { + if (!props.channel?.uniqueName) return; + + const tagConnections = selectedTags.value.map((tag: string) => ({ + onCreate: { node: { text: tag } }, + where: { node: { text: tag } }, + })); + + const tagDisconnections = existingTags.value + .filter((tag: string) => !selectedTags.value.includes(tag)) + .map((tag: string) => ({ + where: { node: { text: tag } }, + })); + + updateChannel({ + where: { uniqueName: props.channel.uniqueName }, + update: { + Tags: [{ connectOrCreate: tagConnections, disconnect: tagDisconnections }], + }, + }); +}; + +const updateSelectedTags = (tags: string[]) => { + selectedTags.value = tags; +};