From 17d99a61daca50f2561c77b5ddd1d4efdf608557 Mon Sep 17 00:00:00 2001 From: viown <48097677+viown@users.noreply.github.com> Date: Fri, 25 Oct 2024 20:58:17 +0300 Subject: [PATCH] Add user-level access controls to playlists --- .../playlisteditor/playlisteditor.ts | 153 +++++++++++++++++- src/components/userpicker/userpicker.js | 60 +++++++ .../userpicker/userpicker.template.html | 22 +++ src/elements/emby-select/emby-select.scss | 3 +- 4 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 src/components/userpicker/userpicker.js create mode 100644 src/components/userpicker/userpicker.template.html diff --git a/src/components/playlisteditor/playlisteditor.ts b/src/components/playlisteditor/playlisteditor.ts index 6297906956..37cc017b87 100644 --- a/src/components/playlisteditor/playlisteditor.ts +++ b/src/components/playlisteditor/playlisteditor.ts @@ -28,6 +28,9 @@ import 'elements/emby-select/emby-select'; import 'material-design-icons-iconfont'; import '../formdialog.scss'; +import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api'; +import type { UserDto } from '@jellyfin/sdk/lib/generated-client/models/user-dto'; +import type { PlaylistUserPermissions } from '@jellyfin/sdk/lib/generated-client/models/playlist-user-permissions'; interface DialogElement extends HTMLDivElement { playlistId?: string @@ -97,6 +100,7 @@ function createPlaylist(dlg: DialogElement) { createPlaylistDto: { Name: name, IsPublic: dlg.querySelector('#chkPlaylistPublic')?.checked, + Users: getUsers(dlg), Ids: itemIds?.split(','), UserId: apiClient.getCurrentUserId() } @@ -127,6 +131,7 @@ function updatePlaylist(dlg: DialogElement) { playlistId: dlg.playlistId, updatePlaylistDto: { Name: name, + Users: getUsers(dlg), IsPublic: dlg.querySelector('#chkPlaylistPublic')?.checked } }) @@ -279,6 +284,21 @@ function getEditorHtml(items: string[], options: PlaylistEditorOptions) { `; + html += ` +
+
+

+ Users +

+ +
+ +
+
+ `; + // newPlaylistInfo html += ''; @@ -295,6 +315,94 @@ function getEditorHtml(items: string[], options: PlaylistEditorOptions) { return html; } +function getPlaylistPermissionsHtml() { + let html = ''; + + html += '
'; + + html += ''; + + html += '
'; + + return html; +} + +function getUsers(page: DialogElement): PlaylistUserPermissions[] { + return Array.prototype.map.call(page.querySelectorAll('.playlistUser'), function (elem) { + return { + UserId: elem.getAttribute('data-user-id'), + CanEdit: Boolean(parseInt(elem.querySelector('select').value, 10)) + }; + }) as PlaylistUserPermissions[]; +} + +function getUserImage(user: UserDto) { + const apiClient = ServerConnections.currentApiClient(); + + let html = ''; + + if (apiClient && user.Id) { + let imageUrl = 'assets/img/avatar.png'; + if (user.PrimaryImageTag) { + imageUrl = apiClient.getUserImageUrl(user.Id, { + width: 35, + tag: user.PrimaryImageTag, + type: 'Primary' + }); + } + + html += ``; + } + + return html; +} + +function addUser(content: DialogElement, user: UserDto, canEdit?: boolean) { + const sharesList = content.querySelector('.sharesList'); + if (sharesList) { + let html = ''; + + html += `
`; + + html += '
'; + + html += ` +
+ ${getUserImage(user)} + ${user.Name} +
`; + + html += '
'; + + html += ` + ${getPlaylistPermissionsHtml()} + `; + + html += '
'; + + sharesList.insertAdjacentHTML('beforeend', html); + const userElement = sharesList.querySelector(`[data-user-id="${user.Id}"]`); + + userElement?.querySelector('.btnDelete')?.addEventListener('click', () => { + userElement.remove(); + }); + + if (canEdit) { + const selectElement = userElement?.querySelector('select'); + if (selectElement) { + selectElement.value = canEdit ? '1' : '0'; + } + } + } +} + function initEditor(content: DialogElement, options: PlaylistEditorOptions, items: string[]) { content.querySelector('#selectPlaylistToAddTo')?.addEventListener('change', function(this: HTMLSelectElement) { if (this.value) { @@ -306,7 +414,35 @@ function initEditor(content: DialogElement, options: PlaylistEditorOptions, item } }); + const apiClient = ServerConnections.getApiClient(currentServerId); + const api = toApi(apiClient); + content.querySelector('form')?.addEventListener('submit', onSubmit); + content.querySelector('#btnAddUser')?.addEventListener('click', (e) => { + e.preventDefault(); + + const shareUsers = getUsers(content).map(user => user.UserId); + + const users = getUserApi(api).getUsers().then(req => { + return req.data.filter(user => user.Id != apiClient.getCurrentUserId() && !shareUsers.includes(user.Id)); + }).catch(err => { + console.error('[PlaylistEditor] failed to fetch users', err); + }); + + import('../userpicker/userpicker').then(({ default: UserPicker }) => { + const picker = new UserPicker(); + + picker.show({ + users: users, + callback: function (selectedUser: UserDto) { + addUser(content, selectedUser); + picker.close(); + } + }); + }).catch(() => { + console.error('[PlaylistEditor] failed to show user picker'); + }); + }); const selectedItemsInput = content.querySelector('.fldSelectedItemIds'); if (selectedItemsInput) { @@ -327,16 +463,15 @@ function initEditor(content: DialogElement, options: PlaylistEditorOptions, item console.error('[PlaylistEditor] could not find dialog element'); return; } - - const apiClient = ServerConnections.getApiClient(currentServerId); - const api = toApi(apiClient); Promise.all([ getUserLibraryApi(api) .getItem({ itemId: options.id }), getPlaylistsApi(api) - .getPlaylist({ playlistId: options.id }) + .getPlaylist({ playlistId: options.id }), + getUserApi(api) + .getUsers() ]) - .then(([ { data: playlistItem }, { data: playlist } ]) => { + .then(([ { data: playlistItem }, { data: playlist }, { data: users } ]) => { panel.playlistId = options.id; const nameField = panel.querySelector('#txtNewPlaylistName'); @@ -344,6 +479,14 @@ function initEditor(content: DialogElement, options: PlaylistEditorOptions, item const publicField = panel.querySelector('#chkPlaylistPublic'); if (publicField) publicField.checked = !!playlist.OpenAccess; + + playlist.Shares?.forEach(shareUser => { + const user = users.find(u => u.Id == shareUser.UserId); + + if (user) { + addUser(panel, user, shareUser.CanEdit); + } + }); }) .catch(err => { console.error('[playlistEditor] failed to get playlist details', err); diff --git a/src/components/userpicker/userpicker.js b/src/components/userpicker/userpicker.js new file mode 100644 index 0000000000..ede168c39b --- /dev/null +++ b/src/components/userpicker/userpicker.js @@ -0,0 +1,60 @@ +import dialogHelper from 'components/dialogHelper/dialogHelper'; +import globalize from 'lib/globalize'; +import template from './userpicker.template.html'; + +function initUsers(page, users) { + const userSelect = page.querySelector('#selectUser'); + + const userOptionsHtml = users.map(function (user) { + return ''; + }); + + userSelect.innerHTML += userOptionsHtml; +} + +class UserPicker { + show = (options) => { + if (options.users != null) { + options.users.then(users => { + const dlg = dialogHelper.createDialog({ + size: 'small', + removeOnClose: true, + scrollY: true + }); + dlg.classList.add('ui-body-a'); + dlg.classList.add('background-theme-a'); + dlg.classList.add('formDialog'); + dlg.innerHTML = globalize.translateHtml(template); + this.currentDialog = dlg; + initUsers(dlg, users); + dialogHelper.open(dlg) + .catch(err => { + console.log('[userpicker] failed to open dialog', err); + }); + + dlg.querySelector('.btnCancel')?.addEventListener('click', () => { + dialogHelper.close(dlg); + }); + dlg.querySelector('.btnCloseDialog')?.addEventListener('click', () => { + dialogHelper.close(dlg); + }); + dlg.querySelector('form').addEventListener('submit', function(e) { + e.preventDefault(); + const selectedUserId = this.querySelector('#selectUser')?.value; + if (selectedUserId && options.callback) { + const selectedUser = users.find(user => user.Id == selectedUserId); + options.callback(selectedUser); + } + }); + }); + } + }; + + close = () => { + if (this.currentDialog) { + dialogHelper.close(this.currentDialog); + } + }; +} + +export default UserPicker; diff --git a/src/components/userpicker/userpicker.template.html b/src/components/userpicker/userpicker.template.html new file mode 100644 index 0000000000..edf2bde5f8 --- /dev/null +++ b/src/components/userpicker/userpicker.template.html @@ -0,0 +1,22 @@ +
+ +

Select User

+
+ +
+
+
+
+ +
+ +
+ +
+
+
+
diff --git a/src/elements/emby-select/emby-select.scss b/src/elements/emby-select/emby-select.scss index d909e8af61..9fbb3f83b1 100644 --- a/src/elements/emby-select/emby-select.scss +++ b/src/elements/emby-select/emby-select.scss @@ -75,6 +75,7 @@ } .selectContainer-inline { + position: relative; display: inline-flex; margin-bottom: 0; align-items: center; @@ -117,7 +118,7 @@ .selectContainer-inline > .selectArrowContainer { top: initial; - bottom: 0.24em; + bottom: 0.03em; font-size: 90%; }