[Feat] Implement UI for soundshot/camshot (#9002)

* feat(videos-ui): add camshots state management and service

Add Akita store, effects, queries, and actions for managing camshot state.
Implement Angular service to fetch camshots from the API endpoint.
Define camshot model interface and class.

* refactor(videos-ui): simplify camshot query and model usage

Update CamshotQuery to directly use ICamshot interface instead of the Camshot class for camshots and camshot observables. This change enhances type safety and reduces unnecessary object instantiation, streamlining the state management for camshots.

* feat(videos-ui): add components for camshot list

Implement UI components for displaying a list of camshots.
Includes:
- camshot-list component (main container)
- camshot-item component (individual item)
- camshot-item-skeleton component (loading placeholder)
Uses Angular's @defer with 'on viewport' trigger for lazy loading individual camshot items and a minimum placeholder duration.

* refactor(videos-ui/camshots): refactor camshot list data flow

Refactor CamshotListComponent to consume camshots data from the
CamshotQuery observable instead of using an input property.
Refactor CamshotItemComponent to define its action buttons internally
based on the camshot's state (e.g., deleted status) and emit specific
events for each action (view, download, recover, delete, hard delete).
Remove the actions input from CamshotItemComponent.
Add CamshotItemComponent, CamshotItemSkeletonComponent, and
CamshotListComponent to the video-ui module declarations as they are
no longer standalone.

* feat(videos-ui): add camshots tab to video detail page

Display camshots associated with the video's time slot in a new tab on the video detail page.
Implement infinite scroll pagination for the camshot list to efficiently load camshots.
Refactor camshot list component to accept the time slot ID as input and manage its own data fetching and state based on the provided ID.

* feat(videos-ui): improve list display and loading

Introduce deferred loading for video and camshot lists on the detail page to
improve initial page load performance using Angular's @defer syntax.
Redesign the camshot item display by changing action buttons to a popover
menu accessed via an icon and updating the layout and styling of the
thumbnail, title, and meta information.
Adjust the initial and reset skip value in the camshot list component
to fix a potential pagination issue.

* refactor(camshot): restructure item skeleton html and style appearance

Restructure the HTML elements of the camshot item skeleton (e.g.,
using divs instead of semantic tags) to provide better hooks for
styling. Update the SCSS to apply new styles and refine the pulsing
animation colors and layout. Add overflow styling to the camshot
list container.

* refactor(video-detail): improve list loading and infinite scroll

Update camshot and similar video lists on the video detail page to:
- Display more informative 'Loading...' messages using ngx-no-data-message.
- Rename 'Videos' tab to 'Similars'.
- Prevent fetching camshots when timeSlotId is not available.
- Enable infinite scroll pagination for camshots only after the
initial data has loaded and timeSlotId is present.
- Remove unused console.log.
- Add minor styling to tab links.

* feat(camshot-item): emit full camshot object from item actions

Change camshot item action outputs to emit the full ICamshot object
instead of just the ID. Update output event emitter types and
button action callbacks to reflect this change.

* refactor(camshot-item): replace static button labels with i18n keys

Prepare the component for internationalization by using localization keys
instead of hardcoded strings for button labels.

* feat(videos-ui/camshot): add view, delete and restore functionality

Implement actions, effects, store updates, and service methods
for managing individual camshots.
Adds the ability to view, soft delete, hard delete, and restore
camshots from the UI.
Updates the camshot item component to emit events for these actions
and adds a delete confirmation dialog in the list component.

* feat(camshot): add loading state to actions and list deleted

Implement loading state feedback for action buttons in the UI.
Introduce an `ActionButton` class to standardize button properties and include a `loading` observable. Update camshot item component to use this class and bind the loading state of delete and recover actions to the respective query states (`deleting$`, `restoring$`). Update the action button component template to display a spinner based on the loading state.
Modify the `ListCamshotQueryHandler` to include deleted camshots (`withDeleted: true`) in the results, enabling recovery and hard delete actions from the list view. Refine the logic for filtering camshots by the current employee's uploads.
Includes minor UI updates: change recover icon to `refresh-outline` and soft delete button status to `danger`.

* feat(camshot): add downloading state for camshot item

Expose a downloading state observable via the query and add it to the camshot item component to show a loading indicator during download.

* feat(camshot): implement soft-deleted camshot recovery

Add backend endpoint, CQRS command, and handler to recover soft-deleted
camshot entities.
A new PATCH /plugins/camshots/:id endpoint is added to the CamshotController
to trigger the recovery command. The RecoverCamshotCommand and its handler
utilize the softRecover method in the CamshotService to restore the entity.
The DeleteCamshotCommandHandler is updated to correctly query for soft-deleted
entities using `withDeleted: true` when handling deletion commands,
particularly for forceDelete operations.
UI changes in the videos-ui plugin add event handlers and actions
(`onRecover`, `onHardDelete`) for the camshot list component to interact
with the new recovery and updated hard delete logic.

* feat(videos-ui/camshots): add camshot download feature

Implement the download camshot feature.
Adds a new action and effect to handle adding camshots to the download queue service.
Connects the download functionality to the camshot list component.
Updates modal titles for clarity and adds styling for no data message.

* feat(videos-ui/camshot): add dialog to view camshots

Implement a modal dialog component for viewing camshot images.
Replaces the console log action when clicking "view" on a camshot with opening
this new viewer dialog.
The viewer includes basic controls like zooming, loading state, and error
handling.
Updated the camshot query to include the 'uploadedBy' relation for displaying
the uploader's name in the viewer metadata.
Updated the camshot model to include the 'uploadedBy' property.
Includes necessary Angular NgOptimizedImage import for performance.

* feat(camshot-viewer): enhance image viewer with pan, drag, and ui updates

Implement image pan and drag functionality when the image is zoomed.
Redesign the user interface with floating header, updated loading/error states, floating zoom controls, and hoverable metadata.
Improve overall layout, styling, and responsiveness of the viewer dialog.

* fix(soundshot): remove IsUrl validator from file url

* feat(videos-ui/soundshot): add soundshot state management

Add Akita state management slice for soundshots, including:
- Actions for fetch, delete, restore, download
- Effects to handle actions and update store
- Query to select state data
- Store to hold soundshot state
- Service to interact with soundshot API
- Model definition

* feat(videos-ui): add sounshots list and player

Implement feature to display and play sounshots.
- Add `SounshotListComponent` with infinite scroll.
- Create `SounshotPlayerComponent` for audio playback.
- Introduce `SounshotPlayerSkeletonComponent` for loading states.
- Integrate list into video detail page with a new tab.
Includes updates to Codacy configuration, Codacy CLI script,
.gitignore, and .vscode/settings.json.

* feat(videos-ui/soundshot-player): add interactive player controls

Implement play/pause, seeking, and volume control for soundshots.
Display track duration and file size.
Refactor component logic using Angular signals and update template/styles.
Remove outdated spec file and Codacy configuration.

* style(soundshot-player): remove max width from player

Allow the player component to take up the full width of its container.

* refactor(soundshot-skeleton): restructure player skeleton

Rewrite the HTML and SCSS for the soundshot player skeleton
component.
Simplifies the layout structure and updates the animation
style. Also changes the background color variable.

* feat(soundshot-player): add cover image and update layout

Add a cover image element to the soundshot player component.
Adjust the layout to display the cover image alongside the soundshot
title and metadata on wider screens. Maintain a stacked layout on
smaller screens.

* style(soundshot-skeleton): improve skeleton layout and animation

Add a placeholder for the cover image to the soundshot player skeleton.
Refactor the layout of the title and meta information to accommodate
the new cover.
Apply the skeleton loading animation consistently to all significant
elements within the skeleton (cover, title, meta items, controls,
progress, time, volume).

* feat(soundshot-player): add actions popover

Add a popover with action buttons to the soundshot player component.
Include download, delete, and conditionally recover/hard delete actions based on the soundshot status.
Allows users to perform actions directly from the player UI.

* style(soundshot-player): update styling and layout

Adopt CSS variable for border radius for consistency.
Change align-items to flex-start in soundshot info.
Add transition for smoother visual updates.
Add specific styles for the actions element.

* feat(soundshot-player): emit player button action events and add loading state

Replace placeholder console.log actions for player buttons with EventEmitter
outputs. This allows parent components to handle the corresponding actions
such as download, recover, soft delete, and hard delete.
Add loading state indication to buttons based on query status for improved
user experience.

* feat(videos-ui): implement soundshot actions

Implement download, recover, soft delete, and hard delete actions for soundshots.
Add event handlers in the soundshot list component to dispatch corresponding
actions. Use confirmation modals for delete and hard delete operations.
Add visual styling to indicate deleted soundshots in the player component.

* fix(soundshot): include soft-deleted entities in delete lookup

When handling the delete command, the service needs to locate the
soundshot entity even if it has been previously soft-deleted.
Using `findOneByOptions` with `{ withDeleted: true }` ensures the
lookup finds the entity regardless of its soft-delete status.

* feat(soundshot/query): include deleted soundshots

Modify the query handler to pass `withDeleted: true` to the
pagination service, ensuring soft-deleted soundshots are
included in the results.

* feat(videos-ui): add toast notifications for restore and delete

Add toast notifications for camshot and soundshot restore and delete
operations.
These toasts inform the user about the status of the operation,
including when it starts, succeeds, or fails. This provides clearer
feedback than just updating the UI state.

* style(soundshot-player): reduce deleted background opacity

* feat(soundshot): add soundshot recovery endpoint and command

Implement command, handler, and controller endpoint to allow soft-recovering
a previously deleted soundshot record.

* feat(soundshot): allow filtering soundshots

Update GetSoundshotsQueryDTO to accept a generic FindOptionsWhere for the
where clause. This removes the previous limitation requiring
TenantOrganizationBaseDTO and a custom transformation.
Utilize the where clause in the soundshot list component to filter by
timeSlotId.

* refactor(soundshot): remove ValidateNested import

* fix(camshot): correctly apply filters to camshot query

When listing camshots, ensure that users without the
CHANGE_SELECTED_EMPLOYEE permission are correctly filtered by
their uploadedById. If a user lacks this permission and does
not have a current employee ID, return an empty result set
immediately to prevent unauthorized data exposure or query errors.
Also, ensure that the dynamically built 'where' clause (from
permissions or date filters) is correctly merged with any base
'where' conditions provided in the query parameters.
Update internal comments for accuracy.

* fix(videos): refine list query filtering and permissions

Add stricter checks in the list videos query handler:
- Ensure `employeeIds` filter is only applied for users
with the `CHANGE_SELECTED_EMPLOYEE` permission.
- Return empty result if user lacks permission and
current employee ID is missing.
- Include soft-deleted records in the query results.
Also update a comment in the list camshot query handler.

* refactor(videos): refactor video count query handler with validation

Add explicit validation for required query options (organizationId,
tenantId) to ensure the handler receives necessary parameters.
Refactor the handler by extracting validation and where condition
building logic into separate private methods. This improves code
structure, readability, and testability.
Enhance JSDoc comments to better explain the handler's purpose,
parameters, and behavior.

* chore(videos-ui): correct sounshot typo in soundshot components

Update component names, selectors, and file paths from "sounshot" to "soundshot" to fix consistent misspelling. This affects the soundshot list, player, and skeleton components and their imports.

* chore(cspell): ignore rules directory

* chore: ignore .codacy directory in spell check

* fix(video-detail-page): update similar tab title

* fix(camshot): correct recover camshot handler filename and import

Correct a typo in the filename for the recover camshot command handler and update the corresponding import path.

* fix(camshot): improve message for camshot recovery not found

Provide more detailed reasons when camshot recovery fails due
to not found or other issues.

* fix(video-detail-page): fix soundshot loading message status

Change the status of the placeholder message displayed while
soundshots are loading from danger to info. Danger status is misleading
for a simple loading state; info status better reflects the ongoing
process.

* fix(video-detail-page): correct loading message and status

Update the loading message from 'Loading camshots...' to
'Loading similar videos...' to accurately reflect the content.
Change the status from 'danger' to 'info' as the loading state
is not a critical error.

* style(camshot-list): clean up formatting

* fix:  incorrect grammar in description

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(soundshot-player): improve seek function robustness

Add checks to ensure the audio player and progress bar elements
are available before attempting to calculate the seek position.
Also add a check for zero width on the progress bar.
Wrap the seek operation in a try/catch block to gracefully handle
potential errors during playback updates.
Include console warnings/errors for debugging missing elements
or failed seek operations.

* fix: Duplicate 'nbButton' attribute on lines 2 and 5. Remove one instance.

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix:  property 'border-radius-top-left' is invalid

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: no feedback shown when isAdded is false. User should be notified if download queue addition fails

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: command should implement ICommand interface from @nestjs/cqrs to follow CQRS pattern

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* feat(soundshot): return recovered soundshot from patch

Return the updated soundshot object after recovery.
This allows the client to immediately use the refreshed state of the
soundshot without needing a subsequent GET request.
BREAKING CHANGE: The PATCH /soundshots/:id endpoint now returns the
recovered soundshot object instead of nothing.

* fix: API description has a typo 'UUID, version UUID the plugin ID'

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* chore: remove sensitive nx cloud access token

Remove the hardcoded token from nx.json.
Environment variables or secrets management
should be used instead.

* style(camshot): fix formatting and API description typo

Correct constructor formatting in CamshotController and RecoverCamshotCommand. Update API description to remove redundancy in the recover camshot operation.

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Kifungo A
2025-06-21 11:07:03 +02:00
committed by GitHub
co-authored by greptile-apps[bot]
parent 6054a721bc
commit 246401d452
67 changed files with 17163 additions and 251 deletions
Executable
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
set -e +o pipefail
# Set up paths first
bin_name="codacy-cli-v2"
# Determine OS-specific paths
os_name=$(uname)
arch=$(uname -m)
case "$arch" in
"x86_64")
arch="amd64"
;;
"x86")
arch="386"
;;
"aarch64"|"arm64")
arch="arm64"
;;
esac
if [ -z "$CODACY_CLI_V2_TMP_FOLDER" ]; then
if [ "$(uname)" = "Linux" ]; then
CODACY_CLI_V2_TMP_FOLDER="$HOME/.cache/codacy/codacy-cli-v2"
elif [ "$(uname)" = "Darwin" ]; then
CODACY_CLI_V2_TMP_FOLDER="$HOME/Library/Caches/Codacy/codacy-cli-v2"
else
CODACY_CLI_V2_TMP_FOLDER=".codacy-cli-v2"
fi
fi
version_file="$CODACY_CLI_V2_TMP_FOLDER/version.yaml"
get_version_from_yaml() {
if [ -f "$version_file" ]; then
local version=$(grep -o 'version: *"[^"]*"' "$version_file" | cut -d'"' -f2)
if [ -n "$version" ]; then
echo "$version"
return 0
fi
fi
return 1
}
get_latest_version() {
local response
if [ -n "$GH_TOKEN" ]; then
response=$(curl -Lq --header "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/codacy/codacy-cli-v2/releases/latest" 2>/dev/null)
else
response=$(curl -Lq "https://api.github.com/repos/codacy/codacy-cli-v2/releases/latest" 2>/dev/null)
fi
handle_rate_limit "$response"
local version=$(echo "$response" | grep -m 1 tag_name | cut -d'"' -f4)
echo "$version"
}
handle_rate_limit() {
local response="$1"
if echo "$response" | grep -q "API rate limit exceeded"; then
fatal "Error: GitHub API rate limit exceeded. Please try again later"
fi
}
download_file() {
local url="$1"
echo "Downloading from URL: ${url}"
if command -v curl > /dev/null 2>&1; then
curl -# -LS "$url" -O
elif command -v wget > /dev/null 2>&1; then
wget "$url"
else
fatal "Error: Could not find curl or wget, please install one."
fi
}
download() {
local url="$1"
local output_folder="$2"
( cd "$output_folder" && download_file "$url" )
}
download_cli() {
# OS name lower case
suffix=$(echo "$os_name" | tr '[:upper:]' '[:lower:]')
local bin_folder="$1"
local bin_path="$2"
local version="$3"
if [ ! -f "$bin_path" ]; then
echo "📥 Downloading CLI version $version..."
remote_file="codacy-cli-v2_${version}_${suffix}_${arch}.tar.gz"
url="https://github.com/codacy/codacy-cli-v2/releases/download/${version}/${remote_file}"
download "$url" "$bin_folder"
tar xzfv "${bin_folder}/${remote_file}" -C "${bin_folder}"
fi
}
# Warn if CODACY_CLI_V2_VERSION is set and update is requested
if [ -n "$CODACY_CLI_V2_VERSION" ] && [ "$1" = "update" ]; then
echo "⚠️ Warning: Performing update with forced version $CODACY_CLI_V2_VERSION"
echo " Unset CODACY_CLI_V2_VERSION to use the latest version"
fi
# Ensure version.yaml exists and is up to date
if [ ! -f "$version_file" ] || [ "$1" = "update" ]; then
echo "ℹ️ Fetching latest version..."
version=$(get_latest_version)
mkdir -p "$CODACY_CLI_V2_TMP_FOLDER"
echo "version: \"$version\"" > "$version_file"
fi
# Set the version to use
if [ -n "$CODACY_CLI_V2_VERSION" ]; then
version="$CODACY_CLI_V2_VERSION"
else
version=$(get_version_from_yaml)
fi
# Set up version-specific paths
bin_folder="${CODACY_CLI_V2_TMP_FOLDER}/${version}"
mkdir -p "$bin_folder"
bin_path="$bin_folder"/"$bin_name"
# Download the tool if not already installed
download_cli "$bin_folder" "$bin_path" "$version"
chmod +x "$bin_path"
run_command="$bin_path"
if [ -z "$run_command" ]; then
fatal "Codacy cli v2 binary could not be found."
fi
if [ "$#" -eq 1 ] && [ "$1" = "download" ]; then
echo "Codacy cli v2 download succeeded"
else
eval "$run_command $*"
fi
+3 -1
View File
@@ -714,6 +714,8 @@
"docker-compose.yml",
"**/agent/src/assets/icons/**",
"**/agent/src/assets/images/**",
"./qodo/**"
"./qodo/**",
"**/rules",
".codacy"
]
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -140,3 +140,6 @@ apps/server-api/src/assets/icons/menu
#Ignore cursor AI rules
.cursor/rules/codacy.mdc
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
+2 -1
View File
@@ -25,5 +25,6 @@
"**/public/**/*.jpg": true,
"**/public/**/*.pdf": true
},
"angular.enable-strict-mode-prompt": false
"angular.enable-strict-mode-prompt": false,
"nxConsole.generateAiAgentRules": true
}
+178 -179
View File
@@ -1,180 +1,179 @@
{
"namedInputs": {
"default": [
"{projectRoot}/**/*",
"sharedGlobals",
"{workspaceRoot}/angular.json",
"{workspaceRoot}/nx.json",
"{workspaceRoot}/tsconfig.base.json"
],
"sharedGlobals": [
"{workspaceRoot}/package.json",
"{workspaceRoot}/tsconfig.json",
"{workspaceRoot}/.eslintrc.json"
],
"production": [
"default"
]
},
"cli": {
"warnings": {
"typescriptMismatch": false,
"versionMismatch": false
},
"packageManager": "yarn",
"analytics": false,
"defaultCollection": "@nstudio/xplat"
},
"generators": {
"@nx/angular": {
"application": {
"linter": "eslint",
"style": "scss",
"unitTestRunner": "jest",
"e2eTestRunner": "cypress",
"strict": true
},
"library": {
"linter": "eslint",
"style": "scss",
"unitTestRunner": "jest",
"buildable": true,
"publishable": false,
"strict": true
},
"component": {
"style": "scss",
"changeDetection": "OnPush",
"prefix": "ngx",
"inlineStyle": false,
"inlineTemplate": false,
"skipTests": false
},
"directive": {
"prefix": "ngx",
"skipTests": false
},
"service": {
"skipTests": false
},
"pipe": {
"skipTests": false
},
"module": {
"skipTests": false
},
"storybook-configuration": {
"linter": "eslint",
"configureCypress": true,
"generateCypressSpecs": true
}
},
"@nx/angular:application": {
"e2eTestRunner": "none",
"linter": "eslint",
"style": "css",
"unitTestRunner": "jest"
},
"@nx/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest"
},
"@nx/angular:component": {
"style": "css"
}
},
"defaultProject": "gauzy",
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"nxCloudAccessToken": "NWJlMDFmMTMtYzUyZS00ZGE4LTk0NzctNWIzZTkxMzNhMTFlfHJlYWQtd3JpdGU=",
"parallel": 1,
"targetDefaults": {
"build": {
"cache": false,
"inputs": [
"production",
"^production"
]
},
"lint": {
"cache": false
},
"test": {
"cache": false
},
"e2e": {
"cache": false
},
"@nx/eslint:lint": {
"cache": false,
"inputs": [
"default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*"
]
},
"@nx/jest:jest": {
"cache": false,
"inputs": [
"default",
"^production",
"{workspaceRoot}/jest.preset.js"
],
"options": {
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"@nx/angular:package": {
"cache": false,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
},
"@nx/webpack:webpack": {
"cache": false,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
},
"@nx/js:tsc": {
"cache": true,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
},
"@angular-devkit/build-angular:application": {
"cache": false,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
}
},
"useInferencePlugins": false,
"defaultBase": "master",
"release": {
"version": {
"preVersionCommand": "yarn nx run-many -t build"
}
}
}
"namedInputs": {
"default": [
"{projectRoot}/**/*",
"sharedGlobals",
"{workspaceRoot}/angular.json",
"{workspaceRoot}/nx.json",
"{workspaceRoot}/tsconfig.base.json"
],
"sharedGlobals": [
"{workspaceRoot}/package.json",
"{workspaceRoot}/tsconfig.json",
"{workspaceRoot}/.eslintrc.json"
],
"production": [
"default"
]
},
"cli": {
"warnings": {
"typescriptMismatch": false,
"versionMismatch": false
},
"packageManager": "yarn",
"analytics": false,
"defaultCollection": "@nstudio/xplat"
},
"generators": {
"@nx/angular": {
"application": {
"linter": "eslint",
"style": "scss",
"unitTestRunner": "jest",
"e2eTestRunner": "cypress",
"strict": true
},
"library": {
"linter": "eslint",
"style": "scss",
"unitTestRunner": "jest",
"buildable": true,
"publishable": false,
"strict": true
},
"component": {
"style": "scss",
"changeDetection": "OnPush",
"prefix": "ngx",
"inlineStyle": false,
"inlineTemplate": false,
"skipTests": false
},
"directive": {
"prefix": "ngx",
"skipTests": false
},
"service": {
"skipTests": false
},
"pipe": {
"skipTests": false
},
"module": {
"skipTests": false
},
"storybook-configuration": {
"linter": "eslint",
"configureCypress": true,
"generateCypressSpecs": true
}
},
"@nx/angular:application": {
"e2eTestRunner": "none",
"linter": "eslint",
"style": "css",
"unitTestRunner": "jest"
},
"@nx/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest"
},
"@nx/angular:component": {
"style": "css"
}
},
"defaultProject": "gauzy",
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"parallel": 1,
"targetDefaults": {
"build": {
"cache": false,
"inputs": [
"production",
"^production"
]
},
"lint": {
"cache": false
},
"test": {
"cache": false
},
"e2e": {
"cache": false
},
"@nx/eslint:lint": {
"cache": false,
"inputs": [
"default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*"
]
},
"@nx/jest:jest": {
"cache": false,
"inputs": [
"default",
"^production",
"{workspaceRoot}/jest.preset.js"
],
"options": {
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"@nx/angular:package": {
"cache": false,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
},
"@nx/webpack:webpack": {
"cache": false,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
},
"@nx/js:tsc": {
"cache": true,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
},
"@angular-devkit/build-angular:application": {
"cache": false,
"dependsOn": [
"^build"
],
"inputs": [
"production",
"^production"
]
}
},
"useInferencePlugins": false,
"defaultBase": "master",
"release": {
"version": {
"preVersionCommand": "yarn nx run-many -t build"
}
}
}
@@ -20,13 +20,14 @@ import {
Get,
HttpStatus,
Param,
Patch,
Post,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ApiConsumes, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiConsumes, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CreateCamshotCommand } from './commands/create-camshot.command';
import { DeleteCamshotCommand } from './commands/delete-camshot.command';
import { CountCamshotDTO } from './dtos/count-camshot.dto';
@@ -37,6 +38,7 @@ import { Camshot } from './entity/camshot.entity';
import { ListCamshotQuery } from './queries';
import { GetCamshotCountQuery } from './queries/get-camshot-count.query';
import { GetCamshotQuery } from './queries/get-camshot.query';
import { RecoverCamshotCommand } from './commands';
@ApiTags('Camshot Plugin')
@UseGuards(TenantPermissionGuard, PermissionGuard)
@@ -210,6 +212,46 @@ export class CamshotController {
return this.queryBus.execute(new GetCamshotQuery(id, options));
}
/**
* Recover a soft-deleted plugin source.
*/
@ApiOperation({
summary: 'Recover a deleted camshot',
description: 'Soft-recovers a previously deleted camshot using its UUID, version UUID the plugin ID.'
})
@ApiParam({
name: 'id',
type: String,
format: 'uuid',
description: 'UUID of the camshot to recover',
required: true
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Camshot recovered successfully.'
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Camshot record not found.'
})
@ApiResponse({
status: HttpStatus.FORBIDDEN,
description: 'User does not have permission to recover this camshot.'
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'Unauthorized access.'
})
@UseValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true
})
@Patch(':id')
public async recover(@Param('id', UUIDValidationPipe) id: ID): Promise<void> {
return this.commandBus.execute(new RecoverCamshotCommand(id));
}
/**
* Delete a camshot record.
*
@@ -6,7 +6,7 @@ import { RequestContext } from '@gauzy/core';
@CommandHandler(DeleteCamshotCommand)
export class DeleteCamshotCommandHandler implements ICommandHandler<DeleteCamshotCommand> {
constructor(private readonly camshotService: CamshotService) { }
constructor(private readonly camshotService: CamshotService) {}
/**
* Handles the `DeleteCamshotCommand` to delete a camshot entity from the database.
@@ -21,14 +21,17 @@ export class DeleteCamshotCommandHandler implements ICommandHandler<DeleteCamsho
public async execute(command: DeleteCamshotCommand): Promise<void> {
const { id, input } = command;
const { forceDelete = false, organizationId, tenantId = RequestContext.currentTenantId() } = input;
const camshot = await this.camshotService.findOneByWhereOptions({ id, organizationId, tenantId });
const camshot = await this.camshotService.findOneByOptions({
where: { id, organizationId, tenantId },
withDeleted: true
});
if (!camshot) {
throw new NotFoundException(`Camshot with id ${id} not found`);
throw new NotFoundException(`Cannot delete because camshot with id ${id} not found`);
}
if (forceDelete) {
await this.camshotService.delete(id);
await this.camshotService.delete(id, { withDeleted: true });
} else {
await this.camshotService.softDelete(id);
}
@@ -1,7 +1,5 @@
import { CreateCamshotCommandHandler } from "./create-camshot-command.handler";
import { DeleteCamshotCommandHandler } from "./delete-camshot-command.handler";
import { CreateCamshotCommandHandler } from './create-camshot-command.handler';
import { DeleteCamshotCommandHandler } from './delete-camshot-command.handler';
import { RecoverCamshotCommandHandler } from './recover-camshot-command.handler';
export const commandHandlers = [
CreateCamshotCommandHandler,
DeleteCamshotCommandHandler
]
export const commandHandlers = [CreateCamshotCommandHandler, DeleteCamshotCommandHandler, RecoverCamshotCommandHandler];
@@ -0,0 +1,25 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { RecoverCamshotCommand } from '../recover-camshot.command';
import { CamshotService } from '../../services/camshot.service';
import { NotFoundException } from '@nestjs/common';
import { ICamshot } from '../../models/camshot.model';
@CommandHandler(RecoverCamshotCommand)
export class RecoverCamshotCommandHandler implements ICommandHandler<RecoverCamshotCommand> {
constructor(private readonly camshotService: CamshotService) { }
public async execute(command: RecoverCamshotCommand): Promise<ICamshot> {
const { id } = command;
const recovered = await this.camshotService.softRecover(id, {
withDeleted: true
});
if (!recovered) {
throw new NotFoundException(
`Camshot with ID '${id}' was not found or could not be recovered. It may not exist, may not be deleted, or there was an issue during the recovery process.`
);
}
return recovered;
}
}
@@ -1,2 +1,3 @@
export * from './create-camshot.command';
export * from './delete-camshot.command';
export * from './recover-camshot.command';
@@ -0,0 +1,6 @@
import { ICommand } from '@nestjs/cqrs';
import { ID } from '@gauzy/contracts';
export class RecoverCamshotCommand implements ICommand {
constructor(public readonly id: ID) { }
}
@@ -41,6 +41,10 @@ export class ListCamshotQueryHandler implements IQueryHandler<ListCamshotQuery>
const hasPermission = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE);
// If the current user doesn't have the permission to select employee, filter by uploadedById
if (!hasPermission) {
// If current employee ID is missing, return empty pagination result
if (!RequestContext.currentEmployeeId()) {
return { items: [], total: 0 };
}
where.uploadedById = RequestContext.currentEmployeeId();
}
@@ -49,7 +53,7 @@ export class ListCamshotQueryHandler implements IQueryHandler<ListCamshotQuery>
// Convert startDate and endDate to UTC based on the provided timeZone
const startDateUtc = moment.tz(startDate, timeZone).utc().toDate();
const endDateUtc = moment.tz(endDate, timeZone).utc().toDate();
// Update the 'valueDate' property to filter records between the specified dates
// Update the 'recordedAt' property to filter records between the specified dates
where.recordedAt = Between(startDateUtc, endDateUtc);
}
@@ -61,7 +65,8 @@ export class ListCamshotQueryHandler implements IQueryHandler<ListCamshotQuery>
// Fetch paginated camshots from the service
return this.camshotService.paginate({
...params,
where
where: { ...where, ...params.where },
withDeleted: true
});
}
}
@@ -1,12 +1,12 @@
import { ICommandHandler, CommandHandler } from "@nestjs/cqrs";
import { SoundshotService } from "../../services/soundshot.service";
import { DeleteSoundshotCommand } from "../delete-soundshot.command";
import { NotFoundException, BadRequestException } from "@nestjs/common";
import { RequestContext } from "@gauzy/core";
import { ICommandHandler, CommandHandler } from '@nestjs/cqrs';
import { SoundshotService } from '../../services/soundshot.service';
import { DeleteSoundshotCommand } from '../delete-soundshot.command';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { RequestContext } from '@gauzy/core';
@CommandHandler(DeleteSoundshotCommand)
export class DeleteSoundshotCommandHandler implements ICommandHandler<DeleteSoundshotCommand> {
constructor(private readonly soundshotService: SoundshotService) { }
constructor(private readonly soundshotService: SoundshotService) {}
/**
* Handles the `DeleteSoundshotCommand` to delete a soundshot entity from the database.
@@ -26,7 +26,10 @@ export class DeleteSoundshotCommandHandler implements ICommandHandler<DeleteSoun
throw new BadRequestException(`Tenant ID ${tenantId} is required for deleting soundshot.`);
}
const soundshot = await this.soundshotService.findOneByWhereOptions({ id, organizationId, tenantId });
const soundshot = await this.soundshotService.findOneByOptions({
where: { id, organizationId, tenantId },
withDeleted: true
});
if (!soundshot) {
throw new NotFoundException(`Soundshot with ID ${id} not found`);
@@ -1,4 +1,9 @@
import { CreateSoundshotCommandHandler } from './create-soundshot-command.handler';
import { DeleteSoundshotCommandHandler } from './delete-soundshot-command.handler';
import { RecoverSoundshotCommandHandler } from './recover-soundshot-command.handler';
export const commandHandlers = [CreateSoundshotCommandHandler, DeleteSoundshotCommandHandler];
export const commandHandlers = [
CreateSoundshotCommandHandler,
DeleteSoundshotCommandHandler,
RecoverSoundshotCommandHandler
];
@@ -0,0 +1,23 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { RecoverSoundshotCommand } from '../recover-soundshot.command';
import { SoundshotService } from '../../services/soundshot.service';
import { NotFoundException } from '@nestjs/common';
import { ISoundshot } from '../../models/soundshot.model';
@CommandHandler(RecoverSoundshotCommand)
export class RecoverSoundshotCommandHandler implements ICommandHandler<RecoverSoundshotCommand> {
constructor(private readonly soundshotService: SoundshotService) {}
public async execute(command: RecoverSoundshotCommand): Promise<ISoundshot> {
const { id } = command;
const recovered = await this.soundshotService.softRecover(id, {
withDeleted: true
});
if (!recovered) {
throw new NotFoundException(`Soundshot with ${id} cannot be recovered`);
}
return recovered;
}
}
@@ -1,2 +1,3 @@
export * from './create-soundshot.command';
export * from './delete-soundshot.command';
export * from './recover-soundshot.command';
@@ -0,0 +1,5 @@
import { ID } from '@gauzy/contracts';
export class RecoverSoundshotCommand {
constructor(public readonly id: ID) {}
}
@@ -7,7 +7,6 @@ import {
IsOptional,
IsPositive,
IsString,
IsUrl,
Matches,
MaxLength,
Min
@@ -99,7 +98,6 @@ export class FileDTO implements UploadedFile {
example: 'https://example.com/soundshot-demo-2024.webm'
})
@IsNotEmpty({ message: 'File URL must not be empty' })
@IsUrl({ protocols: ['https', 'http'] }, { message: 'File URL must be a valid URL' })
@MaxLength(2083, { message: 'File URL must not exceed 2083 characters' }) // 2083 is the maximum URL length in browsers
url: string;
@@ -1,8 +1,8 @@
import { ID } from '@gauzy/contracts';
import { BaseQueryDTO, escapeQueryWithParameters, TenantOrganizationBaseDTO } from '@gauzy/core';
import { BaseQueryDTO } from '@gauzy/core';
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
import { Transform, TransformFnParams, Type } from 'class-transformer';
import { IsArray, IsISO8601, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { IsArray, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator';
import { FindOptionsWhere } from 'typeorm';
import { ISoundshot } from '../models/soundshot.model';
@@ -62,10 +62,6 @@ export class GetSoundshotsQueryDTO extends OmitType(BaseQueryDTO<ISoundshot>, ['
@IsString({ message: 'timeZone must be a string' })
timeZone?: string;
@ApiPropertyOptional({ type: Object })
@IsOptional()
@ValidateNested({ each: true })
@Type(() => TenantOrganizationBaseDTO)
@Transform(({ value }: TransformFnParams) => (value ? escapeQueryWithParameters(value) : {}))
readonly where?: FindOptionsWhere<ISoundshot>;
}
@@ -58,7 +58,8 @@ export class GetSoundshotsQueryHandler implements IQueryHandler<GetSoundshotsQue
// Fetch paginated soundshots from the service
return this.soundshotService.paginate({
...params,
where: { ...where, ...params.where }
where: { ...where, ...params.where },
withDeleted: true
});
}
}
@@ -19,13 +19,14 @@ import {
Get,
HttpStatus,
Param,
Patch,
Post,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ApiConsumes, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiConsumes, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CreateSoundshotCommand } from './commands/create-soundshot.command';
import { DeleteSoundshotCommand } from './commands/delete-soundshot.command';
import { CountSoundshotDTO } from './dtos/count-soundshot.dto';
@@ -38,6 +39,7 @@ import { GetSoundshotCountQuery } from './queries/get-soundshot-count.query';
import { GetSoundshotQuery } from './queries/get-soundshot.query';
import { GetSoundshotsQuery } from './queries/get-soundshots.query';
import { SoundshotService } from './services/soundshot.service';
import { RecoverSoundshotCommand } from './commands';
@ApiTags('Soundshot Plugin')
@UseGuards(TenantPermissionGuard, PermissionGuard)
@@ -48,7 +50,7 @@ export class SoundshotController {
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
private readonly soundshotService: SoundshotService
) {}
) { }
/**
* Get a paginated list of soundshots.
@@ -207,6 +209,46 @@ export class SoundshotController {
return this.queryBus.execute(new GetSoundshotQuery(id, options));
}
/**
* Recover a soft-deleted soundshot.
*/
@ApiOperation({
summary: 'Recover a deleted soundshot',
description: 'Soft-recovers a previously deleted soundshot using its UUID and the plugin ID.'
})
@ApiParam({
name: 'id',
type: String,
format: 'uuid',
description: 'UUID of the soundshot to recover',
required: true
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Soundshot recovered successfully.'
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Soundshot record not found.'
})
@ApiResponse({
status: HttpStatus.FORBIDDEN,
description: 'User does not have permission to recover this soundshot.'
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'Unauthorized access.'
})
@UseValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true
})
@Patch(':id')
public async recover(@Param('id', UUIDValidationPipe) id: ID): Promise<ISoundshot> {
return this.commandBus.execute(new RecoverSoundshotCommand(id));
}
/**
* Delete a soundshot record.
*
@@ -0,0 +1,17 @@
import { ID } from '@gauzy/contracts';
import { createAction } from '@ngneat/effects';
export class CamshotAction {
public static fetchCamshots = createAction('[Camshots] Fetch Camshots', (params?: any) => ({ params }));
public static fetchOneCamshot = createAction('[Camshots] Fetch One Camshot', (id: ID, params?: any) => ({
id,
params
}));
public static deleteCamshot = createAction('[Camshots] Delete Camshot', (id: ID, params?: any) => ({ id, params }));
public static hardDeleteCamshot = createAction('[Camshots] Hard Delete Camshot', (id: ID, params: any) => ({
id,
params
}));
public static restoreCamshot = createAction('[Camshots] Restore Camshot', (id: ID) => ({ id }));
public static downloadCamshot = createAction('[Camshots] Download Camshot', (url: string) => ({ url }));
}
@@ -0,0 +1,161 @@
import { Injectable } from '@angular/core';
import { ErrorHandlingService, ToastrService } from '@gauzy/ui-core/core';
import { createEffect, ofType } from '@ngneat/effects';
import { Actions } from '@ngneat/effects-ng';
import { EMPTY, catchError, finalize, switchMap, tap } from 'rxjs';
import { CamshotStore } from './camshot.store';
import { CamshotService } from '../../shared/services/camshot.service';
import { CamshotAction } from './camshot.action';
import { DownloadQueueService } from '../../shared/services/download/download-queue.service';
@Injectable({ providedIn: 'root' })
export class CamshotEffects {
constructor(
private readonly action$: Actions,
private readonly camshotStore: CamshotStore,
private readonly camshotService: CamshotService,
private readonly downloadQueueService: DownloadQueueService,
private readonly toastrService: ToastrService,
private readonly errorHandler: ErrorHandlingService
) { }
public fetchCamshots$ = createEffect(() =>
this.action$.pipe(
ofType(CamshotAction.fetchCamshots),
tap(() => this.camshotStore.setLoading(true)), // Start loading state
switchMap(({ params = {} }) =>
this.camshotService.getAll(params).pipe(
tap(({ items, total }) =>
this.camshotStore.update((state) => ({
camshots: [
...new Map([...state.camshots, ...items].map((item) => [item.id, item])).values()
],
count: total
}))
),
catchError((error) => {
this.errorHandler.handleError(error); // Handle error properly
return EMPTY; // Return a fallback observable
}),
finalize(() => this.camshotStore.setLoading(false)) // Always stop loading
)
)
)
);
public fetchOneCamshot$ = createEffect(() =>
this.action$.pipe(
ofType(CamshotAction.fetchOneCamshot),
tap(() => this.camshotStore.setLoading(true)), // Start loading state
switchMap(({ id, params = {} }) =>
this.camshotService.getOne(id, params).pipe(
tap((camshot) => this.camshotStore.update({ camshot })),
catchError((error) => {
this.errorHandler.handleError(error); // Handle error properly
return EMPTY; // Return a fallback observable
}),
finalize(() => this.camshotStore.setLoading(false)) // Always stop loading
)
)
)
);
public restore$ = createEffect(() =>
this.action$.pipe(
ofType(CamshotAction.restoreCamshot),
tap(() => {
this.camshotStore.update({ restoring: true });
this.toastrService.info('Restoring camshot...', 'Restore');
}),
switchMap(({ id }) => {
return this.camshotService.restore(id).pipe(
tap(() => {
this.updateStoreAfterRestore(id);
this.toastrService.success('Camshot restored successfully', 'Restore');
}),
finalize(() => this.camshotStore.update({ restoring: false })),
catchError((error) => {
this.errorHandler.handleError(error); // Handle error properly
this.toastrService.error('Failed to restore camshot', 'Restore Error');
return EMPTY; // Return a fallback observable
})
);
})
)
);
public delete$ = createEffect(() =>
this.action$.pipe(
ofType(CamshotAction.deleteCamshot, CamshotAction.hardDeleteCamshot),
tap(() => {
this.camshotStore.update({ deleting: true });
this.toastrService.info('Deleting camshot...', 'Delete');
}),
switchMap(({ id, params = {} }) => {
return this.camshotService.delete(id, params).pipe(
tap(() => {
this.updateStoreAfterDelete(id, params);
this.toastrService.success('Camshot deleted successfully', 'Delete');
}),
finalize(() => this.camshotStore.update({ deleting: false })),
catchError((error) => {
this.errorHandler.handleError(error); // Handle error properly
this.toastrService.error('Failed to delete camshot', 'Delete Error');
return EMPTY; // Return a fallback observable
})
);
})
)
);
addToQueue$ = createEffect(() =>
this.action$.pipe(
ofType(CamshotAction.downloadCamshot),
tap(({ url }) => {
const isAdded = this.downloadQueueService.add([url]);
if (isAdded) {
this.toastrService.info('Camshot added to queue', 'Download Camshot');
}
})
)
);
private updateStoreAfterRestore(id: string) {
this.camshotStore.update((state) => {
const { camshots, camshot } = state;
// Update camshots array - restore matching item
const updatedCamshots = camshots.map((c) => (c.id === id ? { ...c, deletedAt: null } : c));
// Update selected camshot if it matches the restored ID
const updatedCamshot = camshot?.id === id ? { ...camshot, deletedAt: null } : camshot;
return {
camshots: updatedCamshots,
camshot: updatedCamshot
};
});
}
private updateStoreAfterDelete(id: string, params: { forceDelete?: boolean }) {
this.camshotStore.update((state) => {
const { camshots, camshot } = state;
const shouldRemove = params.forceDelete ?? false;
const now = new Date();
// Update camshots array
const updatedCamshots = shouldRemove
? camshots.filter((c) => c.id !== id)
: camshots.map((c) => (c.id === id ? { ...c, deletedAt: now } : c));
// Update selected camshot
const updatedSelectedCamshot =
camshot?.id !== id ? camshot : shouldRemove ? null : { ...camshot, deletedAt: now };
return {
camshots: updatedCamshots,
camshot: updatedSelectedCamshot
};
});
}
}
@@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { Query } from '@datorama/akita';
import { Observable } from 'rxjs';
import { CamshotStore, ICamshotState } from './camshot.store';
import { ICamshot } from '../../shared/models/camshot.model';
@Injectable({ providedIn: 'root' })
export class CamshotQuery extends Query<ICamshotState> {
public readonly camshots$: Observable<ICamshot[]> = this.select((state) => state.camshots);
public readonly camshot$: Observable<ICamshot | null> = this.select((state) => state.camshot);
public readonly count$: Observable<number> = this.select((state) => state.count);
public readonly isLoading$: Observable<boolean> = this.selectLoading();
public readonly isAvailable$: Observable<boolean> = this.select((state) => state.count > 0);
public readonly deleting$: Observable<boolean> = this.select((state) => state.deleting);
public readonly restoring$: Observable<boolean> = this.select((state) => state.restoring);
public readonly downloading$: Observable<boolean> = this.select((state) => state.downloading);
constructor(readonly camshotStore: CamshotStore) {
super(camshotStore);
}
public get camshot(): ICamshot | null {
return this.getValue().camshot;
}
public get camshots(): ICamshot[] {
return this.getValue().camshots || [];
}
public get count(): number {
return this.getValue().count || 0;
}
}
@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { Store, StoreConfig } from '@datorama/akita';
import { ICamshot } from '../../shared/models/camshot.model';
export interface ICamshotState {
count: number;
camshots: ICamshot[];
camshot: ICamshot | null;
deleting: boolean;
restoring: boolean;
downloading: boolean;
}
export function createInitialState(): ICamshotState {
return {
camshots: [],
camshot: null,
count: 0,
deleting: false,
restoring: false,
downloading: false
};
}
@StoreConfig({ name: 'camshots' })
@Injectable({ providedIn: 'root' })
export class CamshotStore extends Store<ICamshotState> {
constructor() {
super(createInitialState());
}
}
@@ -0,0 +1,17 @@
import { ID } from '@gauzy/contracts';
import { createAction } from '@ngneat/effects';
export class SoundshotAction {
public static fetchAll = createAction('[Soundshots] Fetch Soundshots', (params?: any) => ({ params }));
public static fetchOne = createAction('[Soundshots] Fetch One Soundshot', (id: ID, params?: any) => ({
id,
params
}));
public static delete = createAction('[Soundshots] Delete Soundshot', (id: ID, params?: any) => ({ id, params }));
public static hardDelete = createAction('[Soundshots] Hard Delete Soundshot', (id: ID, params: any) => ({
id,
params
}));
public static restore = createAction('[Soundshots] Restore Soundshot', (id: ID) => ({ id }));
public static download = createAction('[Soundshots] Download Soundshot', (url: string) => ({ urls: [url] }));
}
@@ -0,0 +1,163 @@
import { Injectable } from '@angular/core';
import { ErrorHandlingService, ToastrService } from '@gauzy/ui-core/core';
import { createEffect, ofType } from '@ngneat/effects';
import { Actions } from '@ngneat/effects-ng';
import { EMPTY, catchError, finalize, switchMap, tap } from 'rxjs';
import { SoundshotStore } from './soundshot.store';
import { SoundshotService } from '../../shared/services/soundshot.service';
import { SoundshotAction } from './soundshot.action';
import { DownloadQueueService } from '../../shared/services/download/download-queue.service';
@Injectable({ providedIn: 'root' })
export class SoundshotEffects {
constructor(
private readonly action$: Actions,
private readonly soundshotStore: SoundshotStore,
private readonly soundshotService: SoundshotService,
private readonly downloadQueueService: DownloadQueueService,
private readonly toastrService: ToastrService,
private readonly errorHandler: ErrorHandlingService
) { }
public fetchAll$ = createEffect(() =>
this.action$.pipe(
ofType(SoundshotAction.fetchAll),
tap(() => this.soundshotStore.setLoading(true)), // Start loading state
switchMap(({ params = {} }) =>
this.soundshotService.getAll(params).pipe(
tap(({ items, total }) =>
this.soundshotStore.update((state) => ({
soundshots: [
...new Map([...state.soundshots, ...items].map((item) => [item.id, item])).values()
],
count: total
}))
),
catchError((error) => {
this.errorHandler.handleError(error); // Handle error properly
return EMPTY; // Return a fallback observable
}),
finalize(() => this.soundshotStore.setLoading(false)) // Always stop loading
)
)
)
);
public fetchOne$ = createEffect(() =>
this.action$.pipe(
ofType(SoundshotAction.fetchOne),
tap(() => this.soundshotStore.setLoading(true)), // Start loading state
switchMap(({ id, params = {} }) =>
this.soundshotService.getOne(id, params).pipe(
tap((soundshot) => this.soundshotStore.update({ soundshot })),
catchError((error) => {
this.errorHandler.handleError(error); // Handle error properly
return EMPTY; // Return a fallback observable
}),
finalize(() => this.soundshotStore.setLoading(false)) // Always stop loading
)
)
)
);
public restore$ = createEffect(() =>
this.action$.pipe(
ofType(SoundshotAction.restore),
tap(() => {
this.soundshotStore.update({ restoring: true });
this.toastrService.info('Restoring soundshot...', 'Restore');
}),
switchMap(({ id }) => {
return this.soundshotService.restore(id).pipe(
tap(() => {
this.updateStoreAfterRestore(id);
this.toastrService.success('Soundshot restored successfully', 'Restore');
}),
finalize(() => this.soundshotStore.update({ restoring: false })),
catchError((error) => {
this.errorHandler.handleError(error); // Handle error properly
this.toastrService.error('Failed to restore soundshot', 'Restore Error');
return EMPTY; // Return a fallback observable
})
);
})
)
);
public delete$ = createEffect(() =>
this.action$.pipe(
ofType(SoundshotAction.delete, SoundshotAction.hardDelete),
tap(() => {
this.soundshotStore.update({ deleting: true });
this.toastrService.info('Deleting soundshot...', 'Delete');
}),
switchMap(({ id, params = {} }) => {
return this.soundshotService.delete(id, params).pipe(
tap(() => {
this.updateStoreAfterDelete(id, params);
this.toastrService.success('Soundshot deleted successfully', 'Delete');
}),
finalize(() => this.soundshotStore.update({ deleting: false })),
catchError((error) => {
this.errorHandler.handleError(error); // Handle error properly
this.toastrService.error('Failed to delete soundshot', 'Delete Error');
return EMPTY; // Return a fallback observable
})
);
})
)
);
addToQueue$ = createEffect(() =>
this.action$.pipe(
ofType(SoundshotAction.download),
tap(({ urls }) => {
const isAdded = this.downloadQueueService.add(urls);
if (isAdded) {
this.toastrService.info('soundshot added to queue', 'Download Soundshot');
} else {
this.toastrService.error('Failed to add soundshot to queue', 'Download Error');
}
})
)
);
private updateStoreAfterRestore(id: string) {
this.soundshotStore.update((state) => {
const { soundshots, soundshot } = state;
// Update soundshots array - restore matching item
const updatedSoundshots = soundshots.map((c) => (c.id === id ? { ...c, deletedAt: null } : c));
// Update selected soundshot if it matches the restored ID
const updatedSoundshot = soundshot?.id === id ? { ...soundshot, deletedAt: null } : soundshot;
return {
soundshots: updatedSoundshots,
soundshot: updatedSoundshot
};
});
}
private updateStoreAfterDelete(id: string, params: { forceDelete?: boolean }) {
this.soundshotStore.update((state) => {
const { soundshots, soundshot } = state;
const shouldRemove = params.forceDelete ?? false;
const now = new Date();
// Update soundshots array
const updatedSoundshots = shouldRemove
? soundshots.filter((c) => c.id !== id)
: soundshots.map((c) => (c.id === id ? { ...c, deletedAt: now } : c));
// Update selected soundshot
const updatedSelectedSoundshot =
soundshot?.id !== id ? soundshot : shouldRemove ? null : { ...soundshot, deletedAt: now };
return {
soundshots: updatedSoundshots,
soundshot: updatedSelectedSoundshot
};
});
}
}
@@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { Query } from '@datorama/akita';
import { Observable } from 'rxjs';
import { SoundshotStore, ISoundshotState } from './soundshot.store';
import { ISoundshot } from '../../shared/models/soundshot.model';
@Injectable({ providedIn: 'root' })
export class SoundshotQuery extends Query<ISoundshotState> {
public readonly soundshots$: Observable<ISoundshot[]> = this.select((state) => state.soundshots);
public readonly soundshot$: Observable<ISoundshot | null> = this.select((state) => state.soundshot);
public readonly count$: Observable<number> = this.select((state) => state.count);
public readonly isLoading$: Observable<boolean> = this.selectLoading();
public readonly isAvailable$: Observable<boolean> = this.select((state) => state.count > 0);
public readonly deleting$: Observable<boolean> = this.select((state) => state.deleting);
public readonly restoring$: Observable<boolean> = this.select((state) => state.restoring);
public readonly downloading$: Observable<boolean> = this.select((state) => state.downloading);
constructor(readonly soundshotStore: SoundshotStore) {
super(soundshotStore);
}
public get soundshot(): ISoundshot | null {
return this.getValue().soundshot;
}
public get soundshots(): ISoundshot[] {
return this.getValue().soundshots || [];
}
public get count(): number {
return this.getValue().count || 0;
}
}
@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { Store, StoreConfig } from '@datorama/akita';
import { ISoundshot } from '../../shared/models/soundshot.model';
export interface ISoundshotState {
count: number;
soundshots: ISoundshot[];
soundshot: ISoundshot | null;
deleting: boolean;
restoring: boolean;
downloading: boolean;
}
export function createInitialState(): ISoundshotState {
return {
soundshots: [],
soundshot: null,
count: 0,
deleting: false,
restoring: false,
downloading: false
};
}
@StoreConfig({ name: 'soundshots' })
@Injectable({ providedIn: 'root' })
export class SoundshotStore extends Store<ISoundshotState> {
constructor() {
super(createInitialState());
}
}
@@ -0,0 +1,21 @@
@let isLoading = isLoading$ | async;
<!-- Structure -->
<div nbInfiniteList [threshold]="500" [throttleTime]="300" (bottomThreshold)="fetchMoreCamshots()" class="camshot-list">
@for (camshot of camshots$ | async; track camshot.id){ @defer (on viewport) {
<plug-camshot-item
[camshot]="camshot"
(download)="onDownload($event)"
(recover)="onRecover($event)"
(view)="onView($event)"
(delete)="onDelete($event)"
(hardDelete)="onHardDelete($event)"
></plug-camshot-item>
}@placeholder (minimum 500ms) {
<plug-camshot-item-skeleton></plug-camshot-item-skeleton>
} }@empty {
<ngx-no-data-message
status="danger"
[message]="isLoading ? 'Loading camshots...' : 'No camshots found'"
></ngx-no-data-message>
}
</div>
@@ -0,0 +1,7 @@
.camshot-list {
height: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
overflow: auto;
}
@@ -0,0 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CamshotListComponent } from './camshot-list.component';
describe('CamshotListComponent', () => {
let component: CamshotListComponent;
let fixture: ComponentFixture<CamshotListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CamshotListComponent]
}).compileComponents();
fixture = TestBed.createComponent(CamshotListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,163 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
import { ID } from '@gauzy/contracts';
import { Actions } from '@ngneat/effects-ng';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { combineLatest, distinctUntilChanged, filter, map, Observable, take, tap } from 'rxjs';
import { CamshotAction } from '../../+state/camshot/camshot.action';
import { CamshotQuery } from '../../+state/camshot/camshot.query';
import { CamshotStore } from '../../+state/camshot/camshot.store';
import { ICamshot } from '../../shared/models/camshot.model';
import { NbDialogService } from '@nebular/theme';
import { AlertModalComponent } from '@gauzy/ui-core/shared';
import { CamshotViewerComponent } from '../../shared/ui/camshot/camshot-viewer/camshot-viewer.component';
@UntilDestroy({ checkProperties: true })
@Component({
selector: 'plug-camshot-list',
templateUrl: './camshot-list.component.html',
styleUrl: './camshot-list.component.scss',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CamshotListComponent implements OnInit, OnChanges, OnDestroy {
@Input()
public timeSlotId: ID;
private skip = 1;
private hasNext = false;
private readonly take = 10;
constructor(
private readonly camshotQuery: CamshotQuery,
private readonly camshotStore: CamshotStore,
private readonly dialogService: NbDialogService,
private readonly actions: Actions
) { }
ngOnChanges(changes: SimpleChanges): void {
if (changes?.timeSlotId) {
this.reset();
this.fetchCamshots();
}
}
ngOnInit() {
this.camshotQuery
.select()
.pipe(
map(({ count }) => count > this.skip * this.take),
distinctUntilChanged(),
tap((hasNext) => (this.hasNext = hasNext)),
untilDestroyed(this)
)
.subscribe();
}
public onView(camshot: ICamshot) {
this.dialogService.open(CamshotViewerComponent, {
context: {
imageUrl: camshot.fullUrl,
imageTitle: camshot.title,
imageDescription: camshot.uploadedBy?.fullName || 'N/A'
},
hasBackdrop: true
});
}
public onDownload(camshot: ICamshot) {
this.actions.dispatch(CamshotAction.downloadCamshot(camshot.fullUrl));
}
public onRecover(camshot: ICamshot) {
this.actions.dispatch(CamshotAction.restoreCamshot(camshot.id));
}
public onHardDelete(camshot: ICamshot) {
this.dialogService
.open(AlertModalComponent, {
context: {
data: {
message: 'Are you sure you want to delete this camshot definitely?',
title: 'Delete Camshot Forever'
}
},
hasBackdrop: true
})
.onClose.pipe(
take(1),
filter((confirm: 'yes' | 'no') => confirm === 'yes'),
tap(() => this.actions.dispatch(CamshotAction.hardDeleteCamshot(camshot.id, { forceDelete: true })))
)
.subscribe();
}
public onDelete({ id }: ICamshot) {
this.dialogService
.open(AlertModalComponent, {
context: {
data: {
message: 'Are you sure you want to delete this camshot?',
title: 'Delete Camshot'
}
},
hasBackdrop: true
})
.onClose.pipe(
take(1),
filter((confirm: 'yes' | 'no') => confirm === 'yes'),
tap(() => this.actions.dispatch(CamshotAction.deleteCamshot(id)))
)
.subscribe();
}
public fetchCamshots(): void {
if (!this.timeSlotId) {
return;
}
this.actions.dispatch(
CamshotAction.fetchCamshots({
where: {
timeSlotId: this.timeSlotId
},
relations: ['uploadedBy', 'uploadedBy.user'],
skip: this.skip,
take: this.take,
order: { recordedAt: 'DESC' }
})
);
}
public fetchMoreCamshots(): void {
if (this.hasNext && this.unlockInfiniteList) {
this.skip++;
this.fetchCamshots();
}
}
public reset(): void {
this.skip = 1;
this.hasNext = false;
this.camshotStore.update({ camshots: [] });
}
public get camshots$(): Observable<ICamshot[]> {
return this.camshotQuery.camshots$;
}
public get isAvailable$(): Observable<boolean> {
return combineLatest([this.camshotQuery.isAvailable$, this.isLoading$]).pipe(
map(([isAvailable, isLoading]) => isAvailable && !isLoading)
);
}
public get unlockInfiniteList(): boolean {
return this.timeSlotId && this.camshotQuery.camshots.length > 0;
}
public get isLoading$(): Observable<boolean> {
return this.camshotQuery.isLoading$;
}
public ngOnDestroy() {
this.reset();
}
}
@@ -0,0 +1,14 @@
@let isLoading = isLoading$ | async;
<!-- Structure -->
<div nbInfiniteList [threshold]="500" [throttleTime]="300" (bottomThreshold)="fetchMoreSoundshots()"
class="soundshot-list">
@for (soundshot of soundshots$ | async; track soundshot.id){ @defer (on viewport) {
<plug-soundshot-player [soundshot]="soundshot" (download)="onDownload($event)" (recover)="onRecover($event)"
(delete)="onDelete($event)" (hardDelete)="onHardDelete($event)"></plug-soundshot-player>
}@placeholder (minimum 500ms) {
<plug-soundshot-player-skeleton></plug-soundshot-player-skeleton>
} }@empty {
<ngx-no-data-message status="danger"
[message]="isLoading ? 'Loading soundshots...' : 'No soundshots found'"></ngx-no-data-message>
}
</div>
@@ -0,0 +1,7 @@
.soundshot-list {
height: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
overflow: auto;
}
@@ -0,0 +1,151 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
import { ID } from '@gauzy/contracts';
import { Actions } from '@ngneat/effects-ng';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { combineLatest, distinctUntilChanged, filter, map, Observable, take, tap } from 'rxjs';
import { SoundshotAction } from '../../+state/soundshot/soundshot.action';
import { SoundshotQuery } from '../../+state/soundshot/soundshot.query';
import { SoundshotStore } from '../../+state/soundshot/soundshot.store';
import { ISoundshot } from '../../shared/models/soundshot.model';
import { AlertModalComponent } from '@gauzy/ui-core/shared';
import { NbDialogService } from '@nebular/theme';
@UntilDestroy({ checkProperties: true })
@Component({
selector: 'plug-soundshot-list',
standalone: false,
templateUrl: './soundshot-list.component.html',
styleUrl: './soundshot-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SoundshotListComponent implements OnInit, OnChanges, OnDestroy {
@Input()
public timeSlotId: ID;
private skip = 1;
private hasNext = false;
private readonly take = 10;
constructor(
private readonly soundshotQuery: SoundshotQuery,
private readonly soundshotStore: SoundshotStore,
private readonly dialogService: NbDialogService,
private readonly actions: Actions
) { }
ngOnInit() {
this.soundshotQuery
.select()
.pipe(
map(({ count }) => count > this.skip * this.take),
distinctUntilChanged(),
tap((hasNext) => (this.hasNext = hasNext)),
untilDestroyed(this)
)
.subscribe();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes?.timeSlotId) {
this.reset();
this.fetchSoundshots();
}
}
public onDownload(soundshot: ISoundshot) {
this.actions.dispatch(SoundshotAction.download(soundshot.fullUrl));
}
public onRecover(soundshot: ISoundshot) {
this.actions.dispatch(SoundshotAction.restore(soundshot.id));
}
public onHardDelete(soundshot: ISoundshot) {
this.dialogService
.open(AlertModalComponent, {
context: {
data: {
message: 'Are you sure you want to delete this soundshot definitely?',
title: 'Delete Soundshot Forever'
}
},
hasBackdrop: true
})
.onClose.pipe(
take(1),
filter((confirm: 'yes' | 'no') => confirm === 'yes'),
tap(() => this.actions.dispatch(SoundshotAction.hardDelete(soundshot.id, { forceDelete: true })))
)
.subscribe();
}
public onDelete({ id }: ISoundshot) {
this.dialogService
.open(AlertModalComponent, {
context: {
data: {
message: 'Are you sure you want to delete this soundshot?',
title: 'Delete Soundshot'
}
},
hasBackdrop: true
})
.onClose.pipe(
take(1),
filter((confirm: 'yes' | 'no') => confirm === 'yes'),
tap(() => this.actions.dispatch(SoundshotAction.delete(id)))
)
.subscribe();
}
public fetchSoundshots(): void {
if (!this.timeSlotId) {
return;
}
this.actions.dispatch(
SoundshotAction.fetchAll({
where: {
timeSlotId: this.timeSlotId
},
relations: ['uploadedBy', 'uploadedBy.user'],
skip: this.skip,
take: this.take,
order: { recordedAt: 'DESC' }
})
);
}
public fetchMoreSoundshots(): void {
if (this.hasNext && this.unlockInfiniteList) {
this.skip++;
this.fetchSoundshots();
}
}
public reset(): void {
this.skip = 1;
this.hasNext = false;
this.soundshotStore.update({ soundshots: [] });
}
public get soundshots$(): Observable<ISoundshot[]> {
return this.soundshotQuery.soundshots$;
}
public get isAvailable$(): Observable<boolean> {
return combineLatest([this.soundshotQuery.isAvailable$, this.isLoading$]).pipe(
map(([isAvailable, isLoading]) => isAvailable && !isLoading)
);
}
public get unlockInfiniteList(): boolean {
return this.timeSlotId && this.soundshotQuery.soundshots.length > 0;
}
public get isLoading$(): Observable<boolean> {
return this.soundshotQuery.isLoading$;
}
public ngOnDestroy() {
this.reset();
}
}
@@ -1,10 +1,44 @@
@let timeSlotId = (video$ | async)?.timeSlotId;
<!-- Structure -->
<div class="custom-card">
<div #detail class="custom-card-body">
<plug-video class="w-100"></plug-video>
@if(showList$ | async) {
<div nbInfiniteList [threshold]="500" [throttleTime]="300" (bottomThreshold)="fetchMoreVideos()" class="list">
<plug-video-list [vertical]="true"></plug-video-list>
</div>
}
<nb-tabset>
@if(showList$ | async) {
<nb-tab tabTitle="Similar">
@defer(on viewport) {
<div nbInfiniteList [threshold]="500" [throttleTime]="300" (bottomThreshold)="fetchMoreVideos()"
class="list">
<plug-video-list [vertical]="true"></plug-video-list>
</div>
}@placeholder{
<ngx-no-data-message class="no-data" status="info"
message="Loading similar videos..."></ngx-no-data-message>
}
</nb-tab>
}
<nb-tab tabTitle="Camshots">
@defer(on viewport) {
<div class="list">
<plug-camshot-list [timeSlotId]="timeSlotId"></plug-camshot-list>
</div>
}@placeholder {
<ngx-no-data-message class="no-data" status="danger"
message="Loading camshots..."></ngx-no-data-message>
}
</nb-tab>
<nb-tab tabTitle="Soundshots">
@defer(on viewport) {
<div class="list">
<plug-soundshot-list [timeSlotId]="timeSlotId"></plug-soundshot-list>
</div>
}@placeholder {
<ngx-no-data-message class="no-data" status="info"
message="Loading soundshots..."></ngx-no-data-message>
}
</nb-tab>
</nb-tabset>
</div>
</div>
@@ -7,8 +7,27 @@
.list {
overflow-y: auto;
min-width: 416px;
padding-inline-end: 1rem;
height: 100%;
}
.no-data {
margin: 416px;
}
}
::ng-deep nb-tab.content-active {
padding: 1rem;
height: calc(100vh - 22.5rem);
background: var(--gauzy-card-2);
border-radius: var(--border-radius);
&:first-child {
border-top-left-radius: 0;
border: 1px solid red;
}
}
::ng-deep nb-route-tabset .tab-link {
text-transform: unset;
}
@media (max-width: 1435px) {
@@ -14,6 +14,7 @@ import { distinctUntilChanged, map, Observable, tap } from 'rxjs';
import { VideoActions } from '../../+state/video.action';
import { VideoQuery } from '../../+state/video.query';
import { VideoStore } from '../../+state/video.store';
import { IVideo } from '../../shared/models/video.model';
@UntilDestroy({ checkProperties: true })
@Component({
@@ -122,6 +123,10 @@ export class VideoDetailPageComponent implements OnInit, AfterViewInit, OnDestro
);
}
public get video$(): Observable<IVideo> {
return this.videoQuery.video$;
}
ngOnDestroy() {
this.reset();
}
@@ -1,10 +1,35 @@
import { NbComponentStatus } from '@nebular/theme';
import { Observable, of } from 'rxjs';
export interface IActionButton {
label?: string;
icon?: string;
status?: NbComponentStatus;
loading?: Observable<boolean>;
action?: (data?: any) => Promise<void> | void;
disabled?: boolean;
hidden?: boolean;
}
export class ActionButton implements IActionButton {
label?: string;
icon?: string;
status?: NbComponentStatus;
loading?: Observable<boolean>;
action?: (data?: any) => Promise<void> | void;
disabled?: boolean;
hidden?: boolean;
constructor(input: IActionButton) {
Object.assign(
this,
{
loading: of(false),
hidden: false,
disabled: false,
status: 'primary'
},
input
);
}
}
@@ -0,0 +1,31 @@
import { ICamshot as ICamshotCore, ICloneable, ID, IEmployee } from '@gauzy/contracts';
export interface ICamshot extends ICamshotCore {
id: string;
isDeleted?: boolean;
deletedAt?: Date;
uploadedBy?: IEmployee;
}
export class Camshot implements ICamshot, ICloneable<Camshot> {
public id: ID;
public title: string;
public recordedAt?: Date;
public fullUrl?: string;
public thumbUrl?: string;
public size?: number;
public deletedAt?: Date;
public uploadedBy?: IEmployee;
constructor(camshot: ICamshot) {
Object.assign(this, camshot);
}
public get isDeleted(): boolean {
return !!this.deletedAt;
}
public clone(): Camshot {
return new Camshot(this);
}
}
@@ -0,0 +1,31 @@
import { ISoundshot as ISoundshotCore, ICloneable, ID, IEmployee } from '@gauzy/contracts';
export interface ISoundshot extends ISoundshotCore {
id: string;
isDeleted?: boolean;
deletedAt?: Date;
uploadedBy?: IEmployee;
}
export class Soundshot implements ISoundshot, ICloneable<Soundshot> {
public id: ID;
public name: string;
public recordedAt?: Date;
public fullUrl?: string;
public size?: number;
public duration?: number;
public deletedAt?: Date;
public uploadedBy?: IEmployee;
constructor(soundshot: ISoundshot) {
Object.assign(this, soundshot);
}
public get isDeleted(): boolean {
return !!this.deletedAt;
}
public clone(): Soundshot {
return new Soundshot(this);
}
}
@@ -0,0 +1,41 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { IPagination } from '@gauzy/contracts';
import { API_PREFIX, toParams } from '@gauzy/ui-core/common';
import { Observable } from 'rxjs';
import { ICamshot } from '../models/camshot.model';
@Injectable({
providedIn: 'root'
})
export class CamshotService {
private readonly API_ENDPOINT = `${API_PREFIX}/plugins/camshots`;
constructor(private readonly http: HttpClient) {}
public getAll<T>(params?: T): Observable<IPagination<ICamshot>> {
return this.http.get<IPagination<ICamshot>>(this.API_ENDPOINT, {
params: toParams(params)
});
}
public getOne<T>(id: string, params?: T): Observable<ICamshot> {
return this.http.get<ICamshot>(`${this.API_ENDPOINT}/${id}`, { params: toParams(params) });
}
public restore(id: string): Observable<ICamshot> {
return this.http.patch<ICamshot>(`${this.API_ENDPOINT}/${id}`, {});
}
public delete<T>(id: string, params: T): Observable<void> {
return this.http.delete<void>(`${this.API_ENDPOINT}/${id}`, {
params: toParams(params)
});
}
public getCount<T>(params?: T): Observable<number> {
return this.http.get<number>(`${this.API_ENDPOINT}/count`, {
params: toParams(params)
});
}
}
@@ -0,0 +1,41 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { IPagination } from '@gauzy/contracts';
import { API_PREFIX, toParams } from '@gauzy/ui-core/common';
import { Observable } from 'rxjs';
import { ISoundshot } from '../models/soundshot.model';
@Injectable({
providedIn: 'root'
})
export class SoundshotService {
private readonly API_ENDPOINT = `${API_PREFIX}/plugins/soundshots`;
constructor(private readonly http: HttpClient) {}
public getAll<T>(params?: T): Observable<IPagination<ISoundshot>> {
return this.http.get<IPagination<ISoundshot>>(this.API_ENDPOINT, {
params: toParams(params)
});
}
public getOne<T>(id: string, params?: T): Observable<ISoundshot> {
return this.http.get<ISoundshot>(`${this.API_ENDPOINT}/${id}`, { params: toParams(params) });
}
public restore(id: string): Observable<ISoundshot> {
return this.http.patch<ISoundshot>(`${this.API_ENDPOINT}/${id}`, {});
}
public delete<T>(id: string, params: T): Observable<void> {
return this.http.delete<void>(`${this.API_ENDPOINT}/${id}`, {
params: toParams(params)
});
}
public getCount<T>(params?: T): Observable<number> {
return this.http.get<number>(`${this.API_ENDPOINT}/count`, {
params: toParams(params)
});
}
}
@@ -0,0 +1,13 @@
<div class="camshot-item skeleton">
<div class="thumbnail skeleton-box"></div>
<div class="details">
<div class="skeleton-line title"></div>
<div class="meta">
<div class="date skeleton-line"></div>
<div class="size skeleton-line"></div>
</div>
</div>
<div class="actions">
<div class="skeleton-icon"></div>
</div>
</div>
@@ -0,0 +1,74 @@
@keyframes pulse {
0% {
background-color: var(--gauzy-card-2);
}
50% {
background-color: var(--gauzy-card-1);
}
100% {
background-color: var(--gauzy-card-2);
}
}
.details {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.camshot-item.skeleton {
display: flex;
align-items: flex-start;
transition: all 0.3s ease;
gap: 1rem;
&:hover {
box-shadow: var(--gauzy-shadow);
transform: translateY(-2px);
}
.thumbnail.skeleton-box {
width: 100%;
height: 150px;
max-width: 50%;
border-radius: var(--border-radius);
flex-shrink: 0;
animation: pulse 1.5s infinite ease-in-out;
}
.skeleton-line {
height: 1rem;
width: 80%;
animation: pulse 1.5s infinite ease-in-out;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
&.title {
width: 100%;
}
&:last-child {
width: 60%;
}
}
.skeleton-icon {
height: 1.5rem;
/* Typical icon size */
width: 4px;
/* Make it round like an icon */
margin-right: 0.5rem;
/* Typical icon size */
animation: pulse 1.5s infinite ease-in-out;
}
}
@media (max-width: 1435px) {
.thumbnail.skeleton-box {
max-width: 300px !important;
max-height: 150px;
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CamshotItemSkeletonComponent } from './camshot-item-skeleton.component';
describe('CamshotItemSkeletonComponent', () => {
let component: CamshotItemSkeletonComponent;
let fixture: ComponentFixture<CamshotItemSkeletonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CamshotItemSkeletonComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CamshotItemSkeletonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'plug-camshot-item-skeleton',
templateUrl: './camshot-item-skeleton.component.html',
styleUrl: './camshot-item-skeleton.component.scss',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CamshotItemSkeletonComponent {}
@@ -0,0 +1,34 @@
<!-- camshot-item.component.html -->
<div class="camshot-item" [class.deleted]="camshot.isDeleted">
@if (camshot.thumbUrl) {
<div class="thumbnail">
<img [src]="camshot.thumbUrl" [alt]="camshot.title" />
</div>
}
<div class="details">
<div class="title">{{ camshot.title }}</div>
<div class="meta">
<div class="date">
<nb-icon icon="clock-outline"></nb-icon>
{{ camshot.recordedAt | date : 'medium' }}
</div>
@if (camshot.size) {
<div class="size">
<nb-icon icon="hard-drive-outline"></nb-icon>
{{ camshot.size | fileSize }}
</div>
}
</div>
</div>
<div class="actions">
<nb-icon
nbPopoverPlacement="bottom"
[nbPopover]="actions"
icon="more-vertical-outline"
[options]="{ animation: { type: 'zoom' } }"
></nb-icon>
</div>
</div>
<ng-template #actions>
<plug-action-button-group [buttons]="buttons" [data]="camshot"></plug-action-button-group>
</ng-template>
@@ -0,0 +1,66 @@
// camshot-item.component.scss
.camshot-item {
display: flex;
align-items: flex-start;
transition: all 0.3s ease;
&.deleted {
opacity: 0.7;
background: rgba(255, 0, 0, 0.05);
}
&:hover {
box-shadow: var(--gauzy-shadow);
transform: translateY(-2px);
}
.thumbnail {
width: 100%;
height: auto;
max-width: 50%;
margin-right: 1rem;
border-radius: var(--border-radius);
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.details {
flex-grow: 1;
.title {
margin: 0 0 0.5rem 0;
font-size: 1rem;
font-weight: 600;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--gauzy-text-color-2);
nb-icon {
margin-right: 0.25rem;
}
}
}
.actions {
flex-shrink: 0;
cursor: pointer;
}
}
@media (max-width: 1435px) {
.thumbnail {
max-width: 300px !important;
max-height: 200px;
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CamshotItemComponent } from './camshot-item.component';
describe('CamshotItemComponent', () => {
let component: CamshotItemComponent;
let fixture: ComponentFixture<CamshotItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CamshotItemComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CamshotItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,82 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CamshotQuery } from '../../../../+state/camshot/camshot.query';
import { ActionButton, IActionButton } from '../../../models/action-button.model';
import { Camshot, ICamshot } from '../../../models/camshot.model';
@Component({
selector: 'plug-camshot-item',
templateUrl: './camshot-item.component.html',
styleUrls: ['./camshot-item.component.scss'],
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CamshotItemComponent {
private _camshot: Camshot;
constructor(private readonly query: CamshotQuery) {}
@Input()
public set camshot(value: ICamshot) {
this._camshot = new Camshot(value);
this.updateActions();
}
public get camshot(): Camshot {
return this._camshot;
}
@Output() view = new EventEmitter<ICamshot>();
@Output() download = new EventEmitter<ICamshot>();
@Output() recover = new EventEmitter<ICamshot>();
@Output() delete = new EventEmitter<ICamshot>();
@Output() hardDelete = new EventEmitter<ICamshot>();
public buttons: IActionButton[] = [];
private updateActions(): void {
const commonActions: IActionButton[] = [
new ActionButton({
label: 'BUTTONS.VIEW',
icon: 'eye-outline',
status: 'basic',
action: (camshot: ICamshot) => this.view.emit(camshot)
}),
new ActionButton({
icon: 'download-outline',
label: 'BUTTONS.DOWNLOAD',
loading: this.query.downloading$,
status: 'info',
action: (camshot: ICamshot) => this.download.emit(camshot)
})
];
const statusSpecificActions: IActionButton[] = this.camshot.isDeleted
? [
new ActionButton({
icon: 'refresh-outline',
label: 'BUTTONS.RECOVER',
status: 'success',
loading: this.query.restoring$,
action: (camshot: ICamshot) => this.recover.emit(camshot)
}),
new ActionButton({
icon: 'trash-2-outline',
label: 'Hard Delete',
status: 'danger',
loading: this.query.deleting$,
action: (camshot: ICamshot) => this.hardDelete.emit(camshot)
})
]
: [
new ActionButton({
icon: 'trash-outline',
label: 'BUTTONS.DELETE',
loading: this.query.deleting$,
status: 'danger',
action: (camshot: ICamshot) => this.delete.emit(camshot)
})
];
this.buttons = [...commonActions, ...statusSpecificActions];
}
}
@@ -0,0 +1,65 @@
<div class="image-dialog-container">
<!-- Floating header with close button -->
<div class="dialog-header">
<h3 *ngIf="imageTitle">{{ imageTitle }}</h3>
<button nbButton ghost status="basic" class="close-button" (click)="dismiss()">
<nb-icon icon="close-outline"></nb-icon>
</button>
</div>
<!-- Main image container -->
<div class="image-wrapper" (click)="toggleZoom()" [class.grabbing]="isDragging">
<div
class="image-container"
[style.transform]="'scale(' + zoomLevel + ') translate(' + dragX + 'px, ' + dragY + 'px)'"
>
<img [ngSrc]="imageUrl" [alt]="imageTitle" (load)="onImageLoad()" (error)="onImageError()" priority fill />
</div>
<!-- Loading state -->
<div class="loading-overlay" *ngIf="isLoading">
<div class="loading-content">
<div class="spinner-container">
<div class="spinner-circle"></div>
<div class="spinner-circle"></div>
<div class="spinner-circle"></div>
</div>
<p>Loading image...</p>
</div>
</div>
<!-- Error state -->
<div class="error-overlay" *ngIf="showError">
<div class="error-content">
<nb-icon icon="image-off-outline" status="danger"></nb-icon>
<h4>Image failed to load</h4>
<p>We couldn't load the requested image</p>
<button nbButton status="basic" size="small" (click)="dismiss()">Close</button>
</div>
</div>
</div>
<!-- Floating zoom controls -->
<div class="floating-controls">
<div class="control-group zoom-controls">
<button nbButton ghost status="control" (click)="zoomIn()" [disabled]="zoomLevel >= 3">
<nb-icon icon="plus-outline"></nb-icon>
</button>
<div class="zoom-level">{{ zoomLevel.toFixed(1) }}x</div>
<button nbButton ghost status="control" (click)="zoomOut()" [disabled]="zoomLevel <= 0.5">
<nb-icon icon="minus-outline"></nb-icon>
</button>
<button nbButton ghost status="control" (click)="resetZoom()" [disabled]="zoomLevel === 1">
<nb-icon icon="collapse-outline"></nb-icon>
</button>
</div>
</div>
<!-- Image metadata (appears on hover) -->
<div class="image-meta" *ngIf="imageDescription">
<div class="meta-content">
<nb-icon icon="info-circle-outline"></nb-icon>
<p>{{ imageDescription }}</p>
</div>
</div>
</div>
@@ -0,0 +1,272 @@
.image-dialog-container {
position: relative;
width: 90vw;
max-width: 1200px;
height: 90vh;
max-height: 90vh;
display: flex;
flex-direction: column;
background: var(--gauzy-card-2);
border-radius: var(--border-radius);
overflow: hidden;
color: var(--text-basic-color);
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: rgba(var(--background-basic-color-3), 0.8);
backdrop-filter: blur(10px);
z-index: 10;
h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 80%;
color: #ffffff;
}
.close-button {
margin-left: auto;
border-radius: 50%;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
color: #ffffff;
&:hover {
background: rgba(var(--color-basic-600), 0.1);
transform: rotate(90deg);
}
}
}
.image-wrapper {
position: relative;
flex: 1;
overflow: hidden;
cursor: zoom-in;
background: var(--gauzy-card-2);
transition: background 0.3s ease;
backdrop-filter: blur(10px);
&.grabbing {
cursor: grabbing;
}
.image-container {
width: 100%;
height: 100%;
transition: transform 0.2s ease-out;
will-change: transform;
}
img {
position: absolute;
object-fit: contain;
width: 100%;
height: 100%;
}
}
.loading-overlay,
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--background-basic-color-1), 0.9);
backdrop-filter: blur(5px);
z-index: 5;
}
.loading-content {
text-align: center;
padding: 2rem;
max-width: 300px;
.spinner-container {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
.spinner-circle {
width: 12px;
height: 12px;
background: var(--color-primary-500);
border-radius: 50%;
animation: pulse 1.5s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
p {
margin: 0;
color: #ffffff;
font-size: 0.9rem;
}
}
.error-overlay {
.error-content {
text-align: center;
padding: 2rem;
nb-icon {
font-size: 3rem;
margin-bottom: 1rem;
color: var(--color-danger-500);
}
h4 {
margin: 0 0 0.5rem 0;
font-size: 1.2rem;
}
p {
margin: 0 0 1.5rem 0;
color: var(--text-hint-color);
font-size: 0.9rem;
}
}
}
.floating-controls {
position: absolute;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
background: rgba(var(--background-basic-color-3), 0.8);
backdrop-filter: blur(10px);
border-radius: 2rem;
padding: 0.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 10;
.zoom-controls {
display: flex;
align-items: center;
gap: 0.25rem;
button {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
color: #ffffff;
&:hover:not([disabled]) {
background: rgba(var(--color-primary-500), 0.1);
transform: scale(1.1);
}
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
}
.zoom-level {
min-width: 3rem;
text-align: center;
font-size: 0.8rem;
font-weight: 500;
color: #ffffff;
}
}
}
.image-meta {
position: absolute;
bottom: -60px;
left: 0;
right: 0;
padding: 1rem 1.5rem;
background: rgba(var(--background-basic-color-3), 0.8);
backdrop-filter: blur(10px);
transition: bottom 0.3s ease;
z-index: 9;
&:hover {
bottom: 0;
}
.meta-content {
display: flex;
align-items: flex-start;
gap: 0.75rem;
nb-icon {
margin-top: 0.2rem;
flex-shrink: 0;
color: var(--color-primary-500);
}
p {
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
color: #ffffff;
}
}
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.8);
opacity: 0.5;
}
}
// Responsive adjustments
@media (max-width: 768px) {
.image-dialog-container {
width: 100vw;
height: 100vh;
max-height: 100vh;
border-radius: 0;
.floating-controls {
bottom: 1rem;
}
.image-meta {
bottom: -80px;
padding: 0.75rem 1rem;
p {
font-size: 0.8rem;
}
}
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CamshotViewerComponent } from './camshot-viewer.component';
describe('CamshotViewerComponent', () => {
let component: CamshotViewerComponent;
let fixture: ComponentFixture<CamshotViewerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CamshotViewerComponent]
})
.compileComponents();
fixture = TestBed.createComponent(CamshotViewerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,121 @@
import { ChangeDetectionStrategy, Component, HostListener } from '@angular/core';
import { NbDialogRef } from '@nebular/theme';
@Component({
selector: 'camshot-viewer',
templateUrl: './camshot-viewer.component.html',
styleUrls: ['./camshot-viewer.component.scss'],
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CamshotViewerComponent {
imageUrl: string;
imageTitle: string;
imageDescription: string;
isZoomed = false;
isLoading = true;
showError = false;
zoomLevel = 1;
isDragging = false;
dragX = 0;
dragY = 0;
private dragStartX = 0;
private dragStartY = 0;
constructor(protected dialogRef: NbDialogRef<CamshotViewerComponent>) {}
public dismiss(): void {
this.dialogRef.close();
}
public toggleZoom(): void {
this.isZoomed = !this.isZoomed;
this.zoomLevel = this.isZoomed ? 1.5 : 1;
if (!this.isZoomed) {
this.resetPosition();
}
}
public zoomIn(): void {
if (this.zoomLevel < 3) {
this.zoomLevel += 0.25;
this.isZoomed = true;
}
}
public zoomOut(): void {
if (this.zoomLevel > 0.5) {
this.zoomLevel -= 0.25;
this.isZoomed = this.zoomLevel !== 1;
if (!this.isZoomed) {
this.resetPosition();
}
}
}
public resetZoom(): void {
this.zoomLevel = 1;
this.isZoomed = false;
this.resetPosition();
}
public resetPosition(): void {
this.dragX = 0;
this.dragY = 0;
}
public onImageLoad(): void {
this.isLoading = false;
this.showError = false;
}
public onImageError(): void {
this.isLoading = false;
this.showError = true;
}
@HostListener('mousedown', ['$event'])
onMouseDown(event: MouseEvent): void {
if (this.isZoomed && event.button === 0) {
this.isDragging = true;
this.dragStartX = event.clientX - this.dragX;
this.dragStartY = event.clientY - this.dragY;
}
}
@HostListener('document:mousemove', ['$event'])
onMouseMove(event: MouseEvent): void {
if (this.isDragging) {
this.dragX = event.clientX - this.dragStartX;
this.dragY = event.clientY - this.dragStartY;
}
}
@HostListener('document:mouseup')
onMouseUp(): void {
this.isDragging = false;
}
@HostListener('touchstart', ['$event'])
onTouchStart(event: TouchEvent): void {
if (this.isZoomed && event.touches.length === 1) {
this.isDragging = true;
this.dragStartX = event.touches[0].clientX - this.dragX;
this.dragStartY = event.touches[0].clientY - this.dragY;
}
}
@HostListener('document:touchmove', ['$event'])
onTouchMove(event: TouchEvent): void {
if (this.isDragging && event.touches.length === 1) {
this.dragX = event.touches[0].clientX - this.dragStartX;
this.dragY = event.touches[0].clientY - this.dragStartY;
}
}
@HostListener('document:touchend')
onTouchEnd(): void {
this.isDragging = false;
}
}
@@ -0,0 +1,25 @@
<div class="skeleton-container" [class.animate]="animate">
<div class="skeleton-info">
<div class="skeleton-cover"></div>
<div class="skeleton-details">
<div class="skeleton-title"></div>
<div class="skeleton-meta">
<div class="skeleton-meta-item"></div>
<div class="skeleton-meta-item"></div>
</div>
</div>
</div>
<div class="skeleton-player-controls">
<div class="skeleton-btn"></div>
<div class="skeleton-progress-container">
<div class="skeleton-progress-bar"></div>
<div class="skeleton-time-info">
<div class="skeleton-time"></div>
<div class="skeleton-time"></div>
</div>
</div>
@if (showVolumeControl) {
<div class="skeleton-volume-slider"></div>
}
</div>
</div>
@@ -0,0 +1,182 @@
.skeleton-container {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
margin: 0 auto;
padding: 1rem;
background: var(--gauzy-card-3);
border-radius: var(--border-radius);
position: relative;
&.animate {
.skeleton-element {
animation: skeleton-loading 1.5s ease-in-out infinite;
}
}
}
.skeleton-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
}
.skeleton-cover {
width: 48px;
height: 48px;
border-radius: var(--border-radius);
background: var(--skeleton-color);
@extend .skeleton-element;
animation: skeleton-loading 1.5s ease-in-out infinite;
}
.skeleton-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.skeleton-title {
height: 18px;
width: 40%;
border-radius: 4px;
background: var(--skeleton-color);
margin-bottom: 0.1rem;
@extend .skeleton-element;
animation: skeleton-loading 1.5s ease-in-out infinite;
}
.skeleton-meta {
display: flex;
gap: 0.5rem;
}
.skeleton-meta-item {
height: 12px;
width: 32px;
border-radius: 4px;
background: var(--skeleton-color);
@extend .skeleton-element;
animation: skeleton-loading 1.5s ease-in-out infinite;
}
.skeleton-player-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.skeleton-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--skeleton-color);
flex-shrink: 0;
@extend .skeleton-element;
animation: skeleton-loading 1.5s ease-in-out infinite;
}
.skeleton-progress-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.skeleton-progress-bar {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--skeleton-color);
@extend .skeleton-element;
animation: skeleton-loading 1.5s ease-in-out infinite;
}
.skeleton-time-info {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #888;
}
.skeleton-time {
width: 32px;
height: 12px;
border-radius: 4px;
background: var(--skeleton-color);
@extend .skeleton-element;
animation: skeleton-loading 1.5s ease-in-out infinite;
}
.skeleton-volume-slider {
width: 80px;
height: 6px;
border-radius: 3px;
background: var(--skeleton-color);
@extend .skeleton-element;
animation: skeleton-loading 1.5s ease-in-out infinite;
}
// Skeleton styling
.skeleton-element {
background: var(--skeleton-color);
background-size: 200% 100%;
background-image: linear-gradient(
90deg,
var(--skeleton-color) 25%,
var(--skeleton-highlight-color) 50%,
var(--skeleton-color) 75%
);
}
// CSS Variables for skeleton colors
:root {
--skeleton-color: #e2e8f0;
--skeleton-highlight-color: #f1f5f9;
}
// Dark theme skeleton colors
:host-context(.dark-theme) {
--skeleton-color: #334155;
--skeleton-highlight-color: #475569;
}
// Animation
@keyframes skeleton-loading {
0% {
background-color: var(--gauzy-card-2);
}
50% {
background-color: var(--gauzy-card-1);
}
100% {
background-color: var(--gauzy-card-2);
}
}
// Responsive design
@media (max-width: 480px) {
.skeleton-container {
padding: 1rem;
max-width: none;
margin: 0;
}
.skeleton-title {
width: 60%;
}
.skeleton-volume-slider {
width: 50px;
}
.skeleton-info {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
}
@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'plug-soundshot-player-skeleton',
standalone: false,
templateUrl: './soundshot-player-skeleton.component.html',
styleUrl: './soundshot-player-skeleton.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SoundshotPlayerSkeletonComponent {
@Input() showVolumeControl = true;
@Input() animate = true;
}
@@ -0,0 +1,64 @@
<div class="soundshot-player" [class.deleted]="!!soundshot()?.deletedAt">
<div class="soundshot-info">
@if (soundshot()?.uploadedBy?.user?.imageUrl) {
<div class="soundshot-cover">
<img [src]="soundshot()?.uploadedBy?.user?.imageUrl" alt="Cover Image" class="cover-image" />
</div>
}
<div class="soundshot-details">
<div class="soundshot-title">{{ soundshot()?.name }}</div>
<div class="soundshot-meta">
@if (soundshot()?.duration) {
<span>{{ soundshot()?.duration | durationFormat }}s</span>
} @if (soundshot()?.size) {
<span>&bull; {{ soundshot()?.size | fileSize }}</span>
}
</div>
</div>
<div class="actions">
<nb-icon
nbPopoverPlacement="bottom"
[nbPopover]="actions"
icon="more-vertical-outline"
[options]="{ animation: { type: 'zoom' } }"
></nb-icon>
</div>
</div>
<div class="player-controls">
<button class="player-btn" (click)="togglePlay()">
@if (!isPlaying()) {
<span>&#9654;</span>
} @if (isPlaying()) {
<span>&#10073;&#10073;</span>
}
</button>
<div class="progress-container" (click)="seek($event)">
<div class="progress-bar">
<div class="progress" [style.width.%]="progress()"></div>
</div>
<div class="time-info">
<span>{{ currentTime() | durationFormat }}</span>
<span>{{ duration() | durationFormat }}</span>
</div>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
[value]="volume()"
(input)="setVolume($event)"
class="volume-slider"
/>
</div>
<audio
#player
[src]="soundshot()?.fullUrl"
(timeupdate)="onTimeUpdate()"
(loadedmetadata)="onLoadedMetadata()"
(ended)="onEnded()"
></audio>
</div>
<ng-template #actions>
<plug-action-button-group [buttons]="buttons()" [data]="soundshot()"></plug-action-button-group>
</ng-template>
@@ -0,0 +1,139 @@
.soundshot-player {
display: flex;
flex-direction: column;
gap: 0.75rem;
background: var(--gauzy-card-1, #fff);
border-radius: calc(var(--border-radius) * 1.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
padding: 1rem 1.5rem;
margin: 0 auto;
&.deleted {
background: rgba(255, 0, 0, 0.05);
}
}
.soundshot-info {
display: flex;
align-items: flex-start;
gap: 1rem;
transition: all 0.3s ease;
}
.soundshot-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--gauzy-text-color-1, #222);
margin-bottom: 0.1rem;
}
.soundshot-meta {
font-size: 0.85rem;
color: var(--gauzy-text-color-2);
display: flex;
gap: 0.5rem;
}
.player-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.player-btn {
background: #f3f4f6;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
font-size: 1.3rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.player-btn:hover {
background: #e5e7eb;
}
.progress-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
cursor: pointer;
}
.progress-bar {
width: 100%;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
position: relative;
overflow: hidden;
}
.progress {
height: 100%;
background: #6366f1;
border-radius: 3px;
transition: width 0.2s;
}
.time-info {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #888;
}
.volume-slider {
width: 80px;
accent-color: #6366f1;
box-shadow: unset;
}
.soundshot-cover {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0;
}
.cover-image {
max-width: 100%;
max-height: 48px;
border-radius: var(--border-radius);
object-fit: cover;
}
.soundshot-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@media (max-width: 600px) {
.soundshot-player {
padding: 0.75rem 0.5rem;
max-width: 100%;
}
.soundshot-info {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.volume-slider {
width: 50px;
}
}
.actions {
flex-shrink: 0;
cursor: pointer;
margin-left: auto;
}
@@ -0,0 +1,142 @@
import { Component, ElementRef, EventEmitter, Output, ViewChild, computed, input, signal } from '@angular/core';
import { SoundshotQuery } from '../../../../+state/soundshot/soundshot.query';
import { ActionButton, IActionButton } from '../../../models/action-button.model';
import { ISoundshot, Soundshot } from '../../../models/soundshot.model';
@Component({
selector: 'plug-soundshot-player',
standalone: false,
templateUrl: './soundshot-player.component.html',
styleUrl: './soundshot-player.component.scss'
})
export class SoundshotPlayerComponent {
@ViewChild('player') playerRef: ElementRef<HTMLAudioElement>;
soundshot = input<ISoundshot>();
isPlaying = signal(false);
progress = signal(0);
currentTime = signal(0);
duration = signal(0);
volume = signal(1);
@Output() download = new EventEmitter<ISoundshot>();
@Output() recover = new EventEmitter<ISoundshot>();
@Output() delete = new EventEmitter<ISoundshot>();
@Output() hardDelete = new EventEmitter<ISoundshot>();
constructor(private readonly soundshotQuery: SoundshotQuery) { }
public buttons = computed(() => {
const soundshot = new Soundshot(this.soundshot());
const commons = [
new ActionButton({
icon: 'download-outline',
label: 'BUTTONS.DOWNLOAD',
status: 'info',
loading: this.soundshotQuery.downloading$,
action: (soundshot: ISoundshot) => this.download.emit(soundshot)
})
];
const statusSpecificButtons: IActionButton[] = soundshot.isDeleted
? [
new ActionButton({
icon: 'refresh-outline',
label: 'BUTTONS.RECOVER',
status: 'success',
loading: this.soundshotQuery.restoring$,
action: (soundshot: ISoundshot) => this.recover.emit(soundshot)
}),
new ActionButton({
icon: 'trash-2-outline',
label: 'Hard Delete',
status: 'danger',
loading: this.soundshotQuery.deleting$,
action: (soundshot: ISoundshot) => this.hardDelete.emit(soundshot)
})
]
: [
new ActionButton({
icon: 'trash-outline',
label: 'BUTTONS.DELETE',
status: 'danger',
loading: this.soundshotQuery.deleting$,
action: (soundshot: ISoundshot) => this.delete.emit(soundshot)
})
];
return [...commons, ...statusSpecificButtons];
});
// Computed for duration from soundshot
readonly displayDuration = computed(() => this.soundshot()?.duration || this.duration());
togglePlay(): void {
const player = this.playerRef?.nativeElement;
if (!player) return;
if (player.paused) {
player.play();
this.isPlaying.set(true);
} else {
player.pause();
this.isPlaying.set(false);
}
}
onTimeUpdate(): void {
const player = this.playerRef?.nativeElement;
if (!player) return;
this.currentTime.set(player.currentTime);
const dur = this.soundshot()?.duration || player.duration || 0;
this.duration.set(dur);
this.progress.set(dur ? (player.currentTime / dur) * 100 : 0);
}
onLoadedMetadata(): void {
const player = this.playerRef?.nativeElement;
if (!player) return;
const dur = this.soundshot()?.duration || player.duration || 0;
this.duration.set(dur);
}
onEnded(): void {
this.isPlaying.set(false);
this.currentTime.set(0);
this.progress.set(0);
}
seek(event: MouseEvent): void {
const player = this.playerRef?.nativeElement;
const progressBar = (event.currentTarget as HTMLElement)?.querySelector?.('.progress-bar');
if (!player) {
console.warn('Audio player element not found.');
return;
}
if (!progressBar) {
console.warn('Progress bar element not found.');
return;
}
const rect = progressBar.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const width = rect.width;
if (width === 0) {
console.warn('Progress bar width is zero.');
return;
}
const percent = clickX / width;
const duration = this.duration();
const seekTime = percent * (duration || 0);
try {
player.currentTime = seekTime;
this.currentTime.set(seekTime);
this.progress.set(percent * 100);
} catch (error) {
console.error('Failed to seek audio:', error);
}
}
setVolume(event: Event): void {
const player = this.playerRef?.nativeElement;
if (!player) return;
const value = +(event.target as HTMLInputElement).value;
this.volume.set(value);
player.volume = value;
}
}
@@ -1,4 +1,12 @@
<button nbButton class="action" [status]="button.status" [disabled]="button.disabled" (click)="triggerAction()">
<button
nbButton
[nbSpinner]="button.loading | async"
nbSpinnerStatus="success"
class="action"
[status]="button.status"
[disabled]="button.disabled"
(click)="triggerAction()"
>
@if(button.icon) {
<nb-icon [icon]="button.icon"></nb-icon>
} @if(button.label) {
@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { GauzyFiltersModule, NoDataMessageModule, SharedModule } from '@gauzy/ui-core/shared';
import {
NbBadgeModule,
NbButtonModule,
@@ -12,15 +13,16 @@ import {
NbListModule,
NbPopoverModule,
NbProgressBarModule,
NbSpinnerModule
NbSpinnerModule,
NbTabsetModule
} from '@nebular/theme';
import { provideEffects, provideEffectsManager } from '@ngneat/effects-ng';
import { TranslateModule } from '@ngx-translate/core';
import { MomentModule } from 'ngx-moment';
import { GauzyFiltersModule, NoDataMessageModule, SharedModule } from '@gauzy/ui-core/shared';
import { VideoEffects } from './+state/video.effect';
import { VideoQuery } from './+state/video.query';
import { VideoStore } from './+state/video.store';
import { CamshotListComponent } from './features/camshot-list/camshot-list.component';
import { VideoDownloadManagerComponent } from './features/video-download-manager/video-download-manager.component';
import { VideoListComponent } from './features/video-list/video-list.component';
import { VideoComponent } from './features/video/video.component';
@@ -31,6 +33,8 @@ import { FileDownloadService } from './shared/services/download/file-download.se
import { FileSaveStrategy } from './shared/services/download/strategies/file-save.strategy';
import { VideoService } from './shared/services/video.service';
import { WebShareService } from './shared/services/web-share.service';
import { CamshotItemSkeletonComponent } from './shared/ui/camshot/camshot-item-skeleton/camshot-item-skeleton.component';
import { CamshotItemComponent } from './shared/ui/camshot/camshot-item/camshot-item.component';
import { ActionButtonGroupComponent } from './shared/ui/video-actions/buttons/action-button-group/action-button-group.component';
import { ActionButtonComponent } from './shared/ui/video-actions/buttons/action-button/action-button.component';
import { VideoEditComponent } from './shared/ui/video-edit/video-edit.component';
@@ -40,6 +44,12 @@ import { VideoMetadataComponent } from './shared/ui/video-metadata/video-metadat
import { VideoPlayerComponent } from './shared/ui/video-player/video-player.component';
import { VideoSkeletonComponent } from './shared/ui/video-skeleton/video-skeleton.component';
import { VideoUiRoutingModule } from './video-ui-routing.module';
import { CamshotEffects } from './+state/camshot/camshot.effect';
import { CamshotViewerComponent } from './shared/ui/camshot/camshot-viewer/camshot-viewer.component';
import { SoundshotEffects } from './+state/soundshot/soundshot.effect';
import { SoundshotPlayerComponent } from './shared/ui/soundshot/soundshot-player/soundshot-player.component';
import { SoundshotListComponent } from './features/soundshot-list/soundshot-list.component';
import { SoundshotPlayerSkeletonComponent } from './shared/ui/soundshot/soundshot-player-skeleton/soundshot-player-skeleton.component';
@NgModule({
declarations: [
@@ -55,7 +65,14 @@ import { VideoUiRoutingModule } from './video-ui-routing.module';
VideoMetadataComponent,
VideoDownloadManagerComponent,
VideoItemSkeletonComponent,
VideoSkeletonComponent
VideoSkeletonComponent,
CamshotItemComponent,
CamshotItemSkeletonComponent,
CamshotListComponent,
CamshotViewerComponent,
SoundshotPlayerComponent,
SoundshotListComponent,
SoundshotPlayerSkeletonComponent
],
imports: [
CommonModule,
@@ -76,11 +93,13 @@ import { VideoUiRoutingModule } from './video-ui-routing.module';
NbListModule,
MomentModule,
TranslateModule.forChild(),
NbSpinnerModule
NbSpinnerModule,
NbTabsetModule,
NgOptimizedImage
],
providers: [
provideEffectsManager(),
provideEffects(VideoEffects),
provideEffects(VideoEffects, CamshotEffects, SoundshotEffects),
VideoQuery,
VideoStore,
VideoService,
@@ -91,4 +110,4 @@ import { VideoUiRoutingModule } from './video-ui-routing.module';
NbInfiniteListDirective
]
})
export class VideoUiModule {}
export class VideoUiModule { }
@@ -3,33 +3,98 @@ import { VideosService } from '../../services/videos.service';
import { GetVideoCountQuery } from '../get-video-count.query';
import { RequestContext } from '@gauzy/core';
import { PermissionsEnum } from '@gauzy/contracts';
import { BadRequestException } from '@nestjs/common';
/**
* Handler for the `GetVideoCountQuery` that retrieves the count of video entities
* based on the provided query parameters and user permissions.
*/
@QueryHandler(GetVideoCountQuery)
export class GetVideoCountQueryHandler implements IQueryHandler<GetVideoCountQuery> {
/**
* Constructs the `GetVideoCountQueryHandler`.
*
* @param videosService - The service responsible for video-related operations.
*/
constructor(private readonly videosService: VideosService) {}
/**
* Handles the `GetVideoCountQuery` to retrieve the count of video entities.
* Executes the `GetVideoCountQuery` to retrieve the count of video entities.
*
* @param query - The `GetVideoCountQuery` containing the ID of the video to be fetched and optional query options.
*
* @returns A promise resolving to the count of video entities (`number`).
* Validates the query, checks user permissions, builds appropriate where conditions,
* and returns the count of matching video records.
*
* @param query - The `GetVideoCountQuery` containing the query options.
* @returns A Promise that resolves to the count of video entities.
* @throws {BadRequestException} If query options are invalid or required parameters are missing.
*/
public async execute(query: GetVideoCountQuery): Promise<number> {
// Destructure the query to extract the video ID and options
const { options } = query || {};
// Fetch the video entity from the database
const { organizationId, tenantId } = options;
// Check if the current user has the permission
const permission = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE);
// Fetch the count of video entities from the database
return this.videosService.count({
where: {
...(!permission && { uploadedById: RequestContext.currentEmployeeId() }),
organizationId,
tenantId
async execute(query: GetVideoCountQuery): Promise<number> {
// Validate the query parameters
this.validateQuery(query);
// Extract required parameters from query options
const { organizationId, tenantId } = query.options;
// Check if user has permission to view all videos or just their own
const hasPermission = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE);
// Build where conditions based on permissions and query parameters
const whereConditions = this.buildWhereConditions(organizationId, tenantId, hasPermission);
// Return the count of videos matching the conditions
return this.videosService.count({ where: whereConditions });
}
/**
* Validates the query parameters to ensure all required fields are present.
*
* @param query - The `GetVideoCountQuery` to validate.
* @throws {BadRequestException} If any required parameter is missing.
*/
private validateQuery(query: GetVideoCountQuery): void {
if (!query?.options) {
throw new BadRequestException('Query options are required');
}
if (!query.options.organizationId) {
throw new BadRequestException('Organization ID is required');
}
if (!query.options.tenantId) {
throw new BadRequestException('Tenant ID is required');
}
}
/**
* Builds the where conditions for the video count query based on organization,
* tenant, and user permissions.
*
* @param organizationId - The ID of the organization to filter videos by.
* @param tenantId - The ID of the tenant to filter videos by.
* @param hasPermission - Boolean indicating if user has permission to view all videos.
* @returns An object containing the where conditions for the query.
* @throws {BadRequestException} If current employee ID cannot be determined for restricted access.
*/
private buildWhereConditions(
organizationId: string,
tenantId: string,
hasPermission: boolean
): Record<string, any> {
// Base conditions that always apply
const conditions: Record<string, any> = {
organizationId,
tenantId
};
// If user doesn't have permission to view all videos, restrict to their own uploads
if (!hasPermission) {
const currentEmployeeId = RequestContext.currentEmployeeId();
if (!currentEmployeeId) {
throw new BadRequestException('Unable to determine current employee');
}
});
conditions.uploadedById = currentEmployeeId;
}
return conditions;
}
}
@@ -37,9 +37,13 @@ export class GetVideosQueryHandler implements IQueryHandler<GetVideosQuery> {
organizationId
};
const permission = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE);
const hasPermission = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE);
// If the current user doesn't have the permission to select employee, filter by uploadedById
if (!permission) {
if (!hasPermission) {
// If current employee ID is missing, return empty pagination result
if (!RequestContext.currentEmployeeId()) {
return { items: [], total: 0 };
}
where.uploadedById = RequestContext.currentEmployeeId();
}
@@ -48,19 +52,20 @@ export class GetVideosQueryHandler implements IQueryHandler<GetVideosQuery> {
// Convert startDate and endDate to UTC based on the provided timeZone
const startDateUtc = moment.tz(startDate, timeZone).utc().toDate();
const endDateUtc = moment.tz(endDate, timeZone).utc().toDate();
// Update the 'valueDate' property to filter records between the specified dates
// Update the 'recordedAt' property to filter records between the specified dates
where.recordedAt = Between(startDateUtc, endDateUtc);
}
// Add employee filter only if employeeIds is provided and non-empty
if (employeeIds.length > 0) {
if (employeeIds.length > 0 && hasPermission) {
where.uploadedById = In(employeeIds);
}
// Fetch paginated videos from the service
return this.videosService.paginate({
...params,
where
where: { ...where, ...params.where },
withDeleted: true
});
}
}