diff --git a/app/Actions/Album/Create.php b/app/Actions/Album/Create.php index 90a2aec7430..eb776c80b12 100644 --- a/app/Actions/Album/Create.php +++ b/app/Actions/Album/Create.php @@ -150,6 +150,8 @@ private function setStatistics(Album $album): void 'download_count' => 0, 'favourite_count' => 0, 'shared_count' => 0, + 'rating_sum' => 0, + 'rating_count' => 0, ]); } } diff --git a/app/Actions/Album/PositionData.php b/app/Actions/Album/PositionData.php index 9e67f84b5d3..13fa211f3e8 100644 --- a/app/Actions/Album/PositionData.php +++ b/app/Actions/Album/PositionData.php @@ -36,6 +36,7 @@ public function get(AbstractAlbum $album, bool $include_sub_albums = false): Pos }, 'palette', 'tags', + 'rating', ]) ->whereNotNull('latitude') ->whereNotNull('longitude'); diff --git a/app/Actions/Albums/Flow.php b/app/Actions/Albums/Flow.php index a0eda51197c..1dbeb9b8459 100644 --- a/app/Actions/Albums/Flow.php +++ b/app/Actions/Albums/Flow.php @@ -113,7 +113,7 @@ private function getQuery(Album|null $base, bool $with_relations): AlbumBuilder $base_query = Album::query(); if ($with_relations) { - $base_query->with(['cover', 'cover.size_variants', 'statistics', 'photos', 'photos.statistics', 'photos.size_variants', 'photos.palette', 'photos.tags']); + $base_query->with(['cover', 'cover.size_variants', 'statistics', 'photos', 'photos.statistics', 'photos.size_variants', 'photos.palette', 'photos.tags', 'photos.rating']); } // Only join what we need for ordering. diff --git a/app/Actions/Albums/PositionData.php b/app/Actions/Albums/PositionData.php index d8c1fd81a49..1b93a9e2b81 100644 --- a/app/Actions/Albums/PositionData.php +++ b/app/Actions/Albums/PositionData.php @@ -47,6 +47,7 @@ public function do(): PositionDataResource }, 'palette', 'tags', + 'rating', ]) ->whereNotNull('latitude') ->whereNotNull('longitude'), diff --git a/app/Actions/Photo/Pipes/Shared/SaveStatistics.php b/app/Actions/Photo/Pipes/Shared/SaveStatistics.php index bdee4b066b8..a9ad922c37c 100644 --- a/app/Actions/Photo/Pipes/Shared/SaveStatistics.php +++ b/app/Actions/Photo/Pipes/Shared/SaveStatistics.php @@ -26,6 +26,8 @@ public function handle(PhotoDTO $state, \Closure $next): PhotoDTO 'download_count' => 0, 'favourite_count' => 0, 'shared_count' => 0, + 'rating_sum' => 0, + 'rating_count' => 0, ]); $state->getPhoto()->setRelation('statistics', $stats); diff --git a/app/Actions/Photo/Rating.php b/app/Actions/Photo/Rating.php new file mode 100644 index 00000000000..d46bfb130e9 --- /dev/null +++ b/app/Actions/Photo/Rating.php @@ -0,0 +1,103 @@ + 0, no existing rating) + * - Updating existing ratings (rating > 0, existing rating) + * - Removing ratings (rating == 0) + * - Atomic statistics updates + * + * @param Photo $photo The photo to rate + * @param User $user The user rating the photo + * @param int $rating The rating value (0-5, where 0 removes the rating) + * + * @return Photo the photo with refreshed statistics + * + * @throws ConflictingPropertyException if a database conflict occurs during the transaction + */ + public function do(Photo $photo, User $user, int $rating): Photo + { + try { + DB::transaction(function () use ($photo, $user, $rating): void { + // Ensure statistics record exists atomically (Q001-07) + $statistics = Statistics::firstOrCreate( + ['photo_id' => $photo->id], + [ + 'album_id' => null, + 'visit_count' => 0, + 'download_count' => 0, + 'favourite_count' => 0, + 'shared_count' => 0, + 'rating_sum' => 0, + 'rating_count' => 0, + ] + ); + + if ($rating > 0) { + // Find existing rating by this user for this photo + $existing_rating = PhotoRating::where('photo_id', $photo->id) + ->where('user_id', $user->id) + ->first(); + + if ($existing_rating !== null) { + // Update: adjust statistics delta + $delta = $rating - $existing_rating->rating; + $statistics->rating_sum += $delta; + $existing_rating->rating = $rating; + $existing_rating->save(); + } else { + // Insert: create new rating and increment statistics + PhotoRating::create([ + 'photo_id' => $photo->id, + 'user_id' => $user->id, + 'rating' => $rating, + ]); + $statistics->rating_sum += $rating; + $statistics->rating_count++; + } + + $statistics->save(); + } else { + // Rating == 0: remove rating (idempotent, Q001-06) + $existing_rating = PhotoRating::where('photo_id', $photo->id) + ->where('user_id', $user->id) + ->first(); + + if ($existing_rating !== null) { + $statistics->rating_sum -= $existing_rating->rating; + $statistics->rating_count--; + $statistics->save(); + $existing_rating->delete(); + } + // If no existing rating, do nothing (idempotent) + } + }); + + // Reload photo with fresh statistics + $photo->refresh(); + + return $photo; + } catch (\Throwable $e) { + throw new ConflictingPropertyException('Failed to update photo rating due to a conflict. Please try again.', $e); + } + } +} diff --git a/app/Actions/Photo/Timeline.php b/app/Actions/Photo/Timeline.php index 180ccc82f36..d52785eb60b 100644 --- a/app/Actions/Photo/Timeline.php +++ b/app/Actions/Photo/Timeline.php @@ -52,7 +52,7 @@ public function do(): Builder // @codeCoverageIgnoreEnd return $this->photo_query_policy->applySearchabilityFilter( - query: Photo::query()->with(['statistics', 'size_variants', 'statistics', 'palette', 'tags']), + query: Photo::query()->with(['statistics', 'size_variants', 'statistics', 'palette', 'tags', 'rating']), origin: null, include_nsfw: !$this->config_manager->getValueAsBool('hide_nsfw_in_timeline') )->orderBy($order->value, OrderSortingType::DESC->value); diff --git a/app/Actions/Search/PhotoSearch.php b/app/Actions/Search/PhotoSearch.php index b61e854298f..1e9159467f0 100644 --- a/app/Actions/Search/PhotoSearch.php +++ b/app/Actions/Search/PhotoSearch.php @@ -56,7 +56,7 @@ public function query(array $terms): Collection public function sqlQuery(array $terms, ?Album $album = null): Builder { $query = $this->photo_query_policy->applySearchabilityFilter( - query: Photo::query()->with(['albums', 'statistics', 'size_variants', 'palette', 'tags']), + query: Photo::query()->with(['albums', 'statistics', 'size_variants', 'palette', 'tags', 'rating']), origin: $album, include_nsfw: !$this->config_manager->getValueAsBool('hide_nsfw_in_search') ); diff --git a/app/Actions/Tag/GetTagWithPhotos.php b/app/Actions/Tag/GetTagWithPhotos.php index 6f10adda22d..9e81a9d8109 100644 --- a/app/Actions/Tag/GetTagWithPhotos.php +++ b/app/Actions/Tag/GetTagWithPhotos.php @@ -45,7 +45,7 @@ public function do(Tag $tag): TagWithPhotosResource $user = Auth::user(); $base_query = Photo::query() - ->with(['size_variants', 'statistics', 'palette', 'tags']) + ->with(['size_variants', 'statistics', 'palette', 'tags', 'rating']) ->when( $user->may_administrate === false, fn ($q) => $q->where('photos.owner_id', Auth::id()) diff --git a/app/Contracts/Http/Requests/RequestAttribute.php b/app/Contracts/Http/Requests/RequestAttribute.php index fbc17979666..4e1d9f00098 100644 --- a/app/Contracts/Http/Requests/RequestAttribute.php +++ b/app/Contracts/Http/Requests/RequestAttribute.php @@ -83,6 +83,7 @@ class RequestAttribute public const FILE_ATTRIBUTE = 'file'; public const SHALL_OVERRIDE_ATTRIBUTE = 'shall_override'; public const IS_STARRED_ATTRIBUTE = 'is_starred'; + public const RATING_ATTRIBUTE = 'rating'; public const DIRECTION_ATTRIBUTE = 'direction'; public const SINGLE_PATH_ATTRIBUTE = 'path'; diff --git a/app/Enum/ThumbOverlayVisibilityType.php b/app/Enum/VisibilityType.php similarity index 62% rename from app/Enum/ThumbOverlayVisibilityType.php rename to app/Enum/VisibilityType.php index 87d1fb3f237..0a611589632 100644 --- a/app/Enum/ThumbOverlayVisibilityType.php +++ b/app/Enum/VisibilityType.php @@ -9,11 +9,11 @@ namespace App\Enum; /** - * Enum ThumbOverlayVisibilityType. + * Enum VisibilityType. * - * All the allowed display possibilities of the overlay on thumbs + * All the allowed visibility modes for UI elements */ -enum ThumbOverlayVisibilityType: string +enum VisibilityType: string { case NEVER = 'never'; case ALWAYS = 'always'; diff --git a/app/Factories/AlbumFactory.php b/app/Factories/AlbumFactory.php index 0969dafdaec..77659eff3a4 100644 --- a/app/Factories/AlbumFactory.php +++ b/app/Factories/AlbumFactory.php @@ -104,8 +104,8 @@ public function findBaseAlbumOrFail(string $album_id, bool $with_relations = tru $tag_album_query = TagAlbum::query(); if ($with_relations) { - $album_query->with(['access_permissions', 'photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags']); - $tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags']); + $album_query->with(['access_permissions', 'photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']); + $tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']); } $ret = $album_query->find($album_id) ?? $tag_album_query->find($album_id); @@ -173,8 +173,8 @@ public function findBaseAlbumsOrFail(array $album_ids, bool $with_relations = tr $album_query = Album::query(); if ($with_relations) { - $tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags']); - $album_query->with(['photos', 'children', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags']); + $tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']); + $album_query->with(['photos', 'children', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']); } /** @var ($albums_only is true ? array : array)&array */ diff --git a/app/Http/Controllers/Gallery/FrameController.php b/app/Http/Controllers/Gallery/FrameController.php index be6a140fc03..aeb28f7add2 100644 --- a/app/Http/Controllers/Gallery/FrameController.php +++ b/app/Http/Controllers/Gallery/FrameController.php @@ -85,12 +85,12 @@ private function loadPhoto(AbstractAlbum|null $album, int $retries = 5): ?Photo // default query if ($album === null) { $query = $this->photo_query_policy->applySearchabilityFilter( - query: Photo::query()->with(['albums', 'size_variants', 'palette', 'tags']), + query: Photo::query()->with(['albums', 'size_variants', 'palette', 'tags', 'rating']), origin: null, include_nsfw: !request()->configs()->getValueAsBool('hide_nsfw_in_frame') ); } else { - $query = $album->photos()->with(['albums', 'size_variants', 'palette', 'tags']); + $query = $album->photos()->with(['albums', 'size_variants', 'palette', 'tags', 'rating']); } /** @var ?Photo $photo */ diff --git a/app/Http/Controllers/Gallery/PhotoController.php b/app/Http/Controllers/Gallery/PhotoController.php index 22c862d20f1..b91c310b858 100644 --- a/app/Http/Controllers/Gallery/PhotoController.php +++ b/app/Http/Controllers/Gallery/PhotoController.php @@ -11,12 +11,14 @@ use App\Actions\Import\FromUrl; use App\Actions\Photo\Delete; use App\Actions\Photo\MoveOrDuplicate; +use App\Actions\Photo\Rating; use App\Actions\Photo\Rotate; use App\Constants\FileSystem; use App\Contracts\Models\AbstractAlbum; use App\Enum\FileStatus; use App\Enum\SizeVariantType; use App\Exceptions\ConfigurationException; +use App\Exceptions\ConflictingPropertyException; use App\Http\Requests\Photo\CopyPhotosRequest; use App\Http\Requests\Photo\DeletePhotosRequest; use App\Http\Requests\Photo\EditPhotoRequest; @@ -24,6 +26,7 @@ use App\Http\Requests\Photo\MovePhotosRequest; use App\Http\Requests\Photo\RenamePhotoRequest; use App\Http\Requests\Photo\RotatePhotoRequest; +use App\Http\Requests\Photo\SetPhotoRatingRequest; use App\Http\Requests\Photo\SetPhotosStarredRequest; use App\Http\Requests\Photo\SetPhotosTagsRequest; use App\Http\Requests\Photo\UploadPhotoRequest; @@ -166,6 +169,30 @@ public function star(SetPhotosStarredRequest $request): void } } + /** + * Set the rating for a photo. + * + * @param SetPhotoRatingRequest $request + * @param Rating $rating + * + * @return PhotoResource + * + * @throws ConflictingPropertyException + */ + public function rate(SetPhotoRatingRequest $request, Rating $rating): PhotoResource + { + /** @var \App\Models\User $user */ + $user = Auth::user(); + + $photo = $rating->do( + $request->photo(), + $user, + $request->rating() + ); + + return new PhotoResource($photo, null); + } + /** * Moves the photos to an album. */ diff --git a/app/Http/Middleware/ConfigIntegrity.php b/app/Http/Middleware/ConfigIntegrity.php index 64f9a8bffc5..f18359f453d 100644 --- a/app/Http/Middleware/ConfigIntegrity.php +++ b/app/Http/Middleware/ConfigIntegrity.php @@ -91,6 +91,11 @@ class ConfigIntegrity 'flow_carousel_height', 'date_format_flow_published', 'date_format_flow_min_max', + 'rating_public', + 'rating_show_only_when_user_rated', + 'rating_photo_view_mode', + 'rating_show_avg_in_photo_view', + 'rating_album_view_mode', ]; public const PRO_FIELDS = [ diff --git a/app/Http/Requests/Photo/SetPhotoRatingRequest.php b/app/Http/Requests/Photo/SetPhotoRatingRequest.php new file mode 100644 index 00000000000..0fd09343369 --- /dev/null +++ b/app/Http/Requests/Photo/SetPhotoRatingRequest.php @@ -0,0 +1,74 @@ +configs()->getValueAsBool('rating_enabled')) { + return false; + } + + if (Auth::guest()) { + return false; + } + + return Gate::check(PhotoPolicy::CAN_SEE, [Photo::class, $this->photo]); + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + RequestAttribute::PHOTO_ID_ATTRIBUTE => ['required', new RandomIDRule(false)], + RequestAttribute::RATING_ATTRIBUTE => 'required|integer|min:0|max:5', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + /** @var ?string $photo_id */ + $photo_id = $values[RequestAttribute::PHOTO_ID_ATTRIBUTE]; + $this->photo = Photo::query() + ->with(['albums', 'rating']) + ->findOrFail($photo_id); + $this->rating = intval($values[RequestAttribute::RATING_ATTRIBUTE]); + } + + public function rating(): int + { + return $this->rating; + } +} diff --git a/app/Http/Resources/GalleryConfigs/InitConfig.php b/app/Http/Resources/GalleryConfigs/InitConfig.php index e74faa5c771..127719a900f 100644 --- a/app/Http/Resources/GalleryConfigs/InitConfig.php +++ b/app/Http/Resources/GalleryConfigs/InitConfig.php @@ -14,7 +14,7 @@ use App\Enum\PhotoThumbInfoType; use App\Enum\SmallLargeType; use App\Enum\ThumbAlbumSubtitleType; -use App\Enum\ThumbOverlayVisibilityType; +use App\Enum\VisibilityType; use App\Providers\AuthServiceProvider; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\URL; @@ -50,8 +50,8 @@ class InitConfig extends Data public bool $is_mobile_dock_full_transparency_enabled; // Thumbs configuration - public ThumbOverlayVisibilityType $display_thumb_album_overlay; - public ThumbOverlayVisibilityType $display_thumb_photo_overlay; + public VisibilityType $display_thumb_album_overlay; + public VisibilityType $display_thumb_photo_overlay; public ThumbAlbumSubtitleType $album_subtitle_type; public AlbumDecorationType $album_decoration; public AlbumDecorationOrientation $album_decoration_orientation; @@ -104,6 +104,13 @@ class InitConfig extends Data public bool $is_scroll_to_navigate_photos_enabled; public bool $is_swipe_vertically_to_go_back_enabled; + // Rating settings + public bool $is_rating_show_avg_in_details_enabled; + public bool $is_rating_show_avg_in_photo_view_enabled; + public VisibilityType $rating_photo_view_mode; + public bool $is_rating_show_avg_in_album_view_enabled; + public VisibilityType $rating_album_view_mode; + // Homepage public string $default_homepage; public bool $is_timeline_page_enabled = false; @@ -137,8 +144,8 @@ public function __construct() $this->is_mobile_dock_full_transparency_enabled = request()->configs()->getValueAsBool('mobile_dock_full_transparency_enabled'); // Thumbs configuration - $this->display_thumb_album_overlay = request()->configs()->getValueAsEnum('display_thumb_album_overlay', ThumbOverlayVisibilityType::class); - $this->display_thumb_photo_overlay = request()->configs()->getValueAsEnum('display_thumb_photo_overlay', ThumbOverlayVisibilityType::class); + $this->display_thumb_album_overlay = request()->configs()->getValueAsEnum('display_thumb_album_overlay', VisibilityType::class); + $this->display_thumb_photo_overlay = request()->configs()->getValueAsEnum('display_thumb_photo_overlay', VisibilityType::class); $this->album_subtitle_type = request()->configs()->getValueAsEnum('album_subtitle_type', ThumbAlbumSubtitleType::class); $this->album_decoration = request()->configs()->getValueAsEnum('album_decoration', AlbumDecorationType::class); $this->album_decoration_orientation = request()->configs()->getValueAsEnum('album_decoration_orientation', AlbumDecorationOrientation::class); @@ -178,6 +185,13 @@ public function __construct() $this->is_scroll_to_navigate_photos_enabled = request()->configs()->getValueAsBool('is_scroll_to_navigate_photos_enabled'); $this->is_swipe_vertically_to_go_back_enabled = request()->configs()->getValueAsBool('is_swipe_vertically_to_go_back_enabled'); + // Rating settings + $this->is_rating_show_avg_in_details_enabled = request()->configs()->getValueAsBool('rating_show_avg_in_details'); + $this->is_rating_show_avg_in_photo_view_enabled = request()->configs()->getValueAsBool('rating_show_avg_in_photo_view'); + $this->rating_photo_view_mode = request()->configs()->getValueAsEnum('rating_photo_view_mode', VisibilityType::class); + $this->is_rating_show_avg_in_album_view_enabled = request()->configs()->getValueAsBool('rating_show_avg_in_album_view'); + $this->rating_album_view_mode = request()->configs()->getValueAsEnum('rating_album_view_mode', VisibilityType::class); + // Homepage $this->default_homepage = request()->configs()->getValueAsString('home_page_default'); $this->is_timeline_page_enabled = request()->configs()->getValueAsBool('timeline_page_enabled'); diff --git a/app/Http/Resources/Models/PhotoRatingResource.php b/app/Http/Resources/Models/PhotoRatingResource.php new file mode 100644 index 00000000000..b8a832ecd93 --- /dev/null +++ b/app/Http/Resources/Models/PhotoRatingResource.php @@ -0,0 +1,59 @@ +rating_count, + $stats->rating_avg, + ); + } + + // User is logged in. + if ($rating === null && $config_manager->getValueAsBool('rating_show_only_when_user_rated')) { + // If rating is null, user did not rate the photo => hide the values with 0. + return new self( + 0, + 0, + 0, + ); + } + + return new self( + $rating?->rating ?? 0, + $stats->rating_count, + $stats->rating_avg, + ); + } +} diff --git a/app/Http/Resources/Models/PhotoResource.php b/app/Http/Resources/Models/PhotoResource.php index 56e8d640b37..91f73ed32eb 100644 --- a/app/Http/Resources/Models/PhotoResource.php +++ b/app/Http/Resources/Models/PhotoResource.php @@ -66,6 +66,7 @@ class PhotoResource extends Data private Carbon $timeline_data_carbon; public ?PhotoStatisticsResource $statistics = null; + public ?PhotoRatingResource $rating = null; public function __construct(Photo $photo, ?AbstractAlbum $album) { @@ -110,12 +111,15 @@ public function __construct(Photo $photo, ?AbstractAlbum $album) if (request()->configs()->getValueAsBool('metrics_enabled') && Gate::check(PhotoPolicy::CAN_READ_METRICS, [Photo::class, $photo])) { $this->statistics = PhotoStatisticsResource::fromModel($photo->statistics); } - } - // public static function fromModel(Photo $photo): PhotoResource - // { - // return new self($photo); - // } + if (Gate::check(PhotoPolicy::CAN_READ_RATINGS, [Photo::class, $photo])) { + $this->rating = PhotoRatingResource::fromModel( + $photo->statistics, + $photo->rating, + request()->configs(), + ); + } + } private function setLocation(Photo $photo): void { diff --git a/app/Http/Resources/Models/PhotoStatisticsResource.php b/app/Http/Resources/Models/PhotoStatisticsResource.php index ca406d0daf1..39f286b8950 100644 --- a/app/Http/Resources/Models/PhotoStatisticsResource.php +++ b/app/Http/Resources/Models/PhotoStatisticsResource.php @@ -20,6 +20,8 @@ public function __construct( public int $download_count = 0, public int $favourite_count = 0, public int $shared_count = 0, + public int $rating_count = 0, + public ?float $rating_avg = null, ) { } @@ -34,6 +36,8 @@ public static function fromModel(Statistics|null $stats): PhotoStatisticsResourc $stats->download_count, $stats->favourite_count, $stats->shared_count, + $stats->rating_count, + $stats->rating_avg, ); } } diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 4387a1f05fe..ca16223b6ef 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -40,6 +40,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use function Safe\preg_match; @@ -230,6 +231,31 @@ public function purchasable(): HasMany return $this->hasMany(Purchasable::class, 'photo_id', 'id'); } + /** + * Get all ratings for this photo. + * + * @return HasMany + * + * @codeCoverageIgnore Just a simple relationship - Not used yet. + */ + public function ratings(): HasMany + { + return $this->hasMany(PhotoRating::class, 'photo_id', 'id'); + } + + /** + * Get all ratings for this photo. + * + * @return HasOne + */ + public function rating(): HasOne + { + /** @phpstan-ignore return.type (because of when() method used in the return statement) */ + return $this->hasOne(PhotoRating::class) + ->when(Auth::check(), fn ($query) => $query->where('user_id', '=', Auth::id())) + ->when(!Auth::check(), fn ($query) => $query->whereNull('user_id')); + } + /** * Returns the relationship between a photo and its associated color palette. * diff --git a/app/Models/PhotoRating.php b/app/Models/PhotoRating.php new file mode 100644 index 00000000000..7dfe88df659 --- /dev/null +++ b/app/Models/PhotoRating.php @@ -0,0 +1,72 @@ + 'integer', + 'user_id' => 'integer', + ]; + + /** + * Get the photo that this rating belongs to. + * + * @return BelongsTo + */ + public function photo(): BelongsTo + { + return $this->belongsTo(Photo::class, 'photo_id', 'id'); + } + + /** + * Get the user who created this rating. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'id'); + } +} diff --git a/app/Models/Statistics.php b/app/Models/Statistics.php index 21f8153536e..5ce39198028 100644 --- a/app/Models/Statistics.php +++ b/app/Models/Statistics.php @@ -23,6 +23,9 @@ * @property int $download_count * @property int $favourite_count * @property int $shared_count + * @property int $rating_sum + * @property int $rating_count + * @property float $rating_avg * * @method static StatisticsBuilder|Statistics addSelect($column) * @method static StatisticsBuilder|Statistics join(string $table, string $first, string $operator = null, string $second = null, string $type = 'inner', string $where = false) @@ -65,5 +68,23 @@ public function newEloquentBuilder($query): StatisticsBuilder 'download_count', 'favourite_count', 'shared_count', + 'rating_sum', + 'rating_count', ]; + + protected $casts = [ + 'rating_sum' => 'integer', + 'rating_count' => 'integer', + ]; + + /** + * Get the average rating (sum / count). + * Returns 0 if no ratings exist. + * + * @return float + */ + protected function getRatingAvgAttribute(): float + { + return $this->rating_count === 0 ? 0 : round($this->rating_sum / $this->rating_count, 2); + } } diff --git a/app/Policies/PhotoPolicy.php b/app/Policies/PhotoPolicy.php index 8b5bc591a9e..65c7bc674bf 100644 --- a/app/Policies/PhotoPolicy.php +++ b/app/Policies/PhotoPolicy.php @@ -30,6 +30,7 @@ class PhotoPolicy extends BasePolicy public const CAN_ACCESS_FULL_PHOTO = 'canAccessFullPhoto'; public const CAN_DELETE_BY_ID = 'canDeleteById'; public const CAN_READ_METRICS = 'canReadMetrics'; + public const CAN_READ_RATINGS = 'canReadRatings'; /** * @throws FrameworkException @@ -258,6 +259,25 @@ public function canReadMetrics(?User $user, Photo $photo): bool }; } + /** + * @param User|null $user + * @param Photo $photo + * + * @return bool + */ + public function canReadRatings(?User $user, Photo $photo): bool + { + $config_manager = app(ConfigManager::class); + // Rating are disabled globally + if (!$config_manager->getValueAsBool('rating_enabled')) { + return false; + } + + // Note that this will bypass the setting 'rating_show_only_when_user_rated' + // It is up to the admin to decide whether anonymous users can see ratings at all. + return ($user !== null) || $config_manager->getValueAsBool('rating_public'); + } + /** * @param Collection $albums * @param \Closure(Album $album): bool $reducer diff --git a/app/SmartAlbums/BaseSmartAlbum.php b/app/SmartAlbums/BaseSmartAlbum.php index 737c0f343a6..ab2a6c7be39 100644 --- a/app/SmartAlbums/BaseSmartAlbum.php +++ b/app/SmartAlbums/BaseSmartAlbum.php @@ -107,7 +107,7 @@ public function get_photos(): LengthAwarePaginator */ public function photos(): Builder { - $base_query = Photo::query()->leftJoin(PA::PHOTO_ALBUM, 'photos.id', '=', PA::PHOTO_ID)->with(['size_variants', 'statistics', 'palette', 'tags']); + $base_query = Photo::query()->leftJoin(PA::PHOTO_ALBUM, 'photos.id', '=', PA::PHOTO_ID)->with(['size_variants', 'statistics', 'palette', 'tags', 'rating']); if (!$this->config_manager->getValueAsBool('SA_override_visibility')) { return $this->photo_query_policy diff --git a/app/SmartAlbums/UnsortedAlbum.php b/app/SmartAlbums/UnsortedAlbum.php index d8d36e50a08..af16a242a5e 100644 --- a/app/SmartAlbums/UnsortedAlbum.php +++ b/app/SmartAlbums/UnsortedAlbum.php @@ -48,7 +48,7 @@ public function photos(): Builder { $config_manager = resolve(ConfigManager::class); if ($this->public_permissions !== null && (!Auth::check() || !$config_manager->getValueAsBool('enable_smart_album_per_owner'))) { - return Photo::query()->leftJoin(PA::PHOTO_ALBUM, 'photos.id', '=', PA::PHOTO_ID)->with(['size_variants', 'statistics', 'palette', 'tags']) + return Photo::query()->leftJoin(PA::PHOTO_ALBUM, 'photos.id', '=', PA::PHOTO_ID)->with(['size_variants', 'statistics', 'palette', 'tags', 'rating']) ->where($this->smart_photo_condition); } diff --git a/database/factories/StatisticsFactory.php b/database/factories/StatisticsFactory.php index a8dd8b4ed17..bc33a16c64c 100644 --- a/database/factories/StatisticsFactory.php +++ b/database/factories/StatisticsFactory.php @@ -37,6 +37,8 @@ public function definition(): array 'download_count' => 0, 'favourite_count' => 0, 'shared_count' => 0, + 'rating_sum' => 0, + 'rating_count' => 0, ]; } diff --git a/database/migrations/2025_12_27_034137_create_photo_ratings_table.php b/database/migrations/2025_12_27_034137_create_photo_ratings_table.php new file mode 100644 index 00000000000..acbd4b1fded --- /dev/null +++ b/database/migrations/2025_12_27_034137_create_photo_ratings_table.php @@ -0,0 +1,50 @@ +id(); + $table->char('photo_id', self::RANDOM_ID_LENGTH)->index(); + $table->unsignedInteger('user_id')->index(); + $table->unsignedTinyInteger('rating')->comment('Rating value 1-5'); + + // Unique constraint: one rating per user per photo + $table->unique(['photo_id', 'user_id']); + + // Foreign key constraints with CASCADE delete + $table->foreign('photo_id') + ->references('id') + ->on('photos') + ->onDelete('cascade'); + + $table->foreign('user_id') + ->references('id') + ->on('users') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('photo_ratings'); + } +}; diff --git a/database/migrations/2025_12_27_034141_add_rating_columns_to_photo_statistics.php b/database/migrations/2025_12_27_034141_add_rating_columns_to_photo_statistics.php new file mode 100644 index 00000000000..aee311219bb --- /dev/null +++ b/database/migrations/2025_12_27_034141_add_rating_columns_to_photo_statistics.php @@ -0,0 +1,37 @@ +unsignedBigInteger(self::COL_RATING_SUM)->default(0)->comment('Sum of all rating values for this photo'); + $table->unsignedInteger(self::COL_RATING_COUNT)->default(0)->comment('Number of ratings for this photo'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('statistics', function (Blueprint $table) { + $table->dropColumn([self::COL_RATING_SUM, self::COL_RATING_COUNT]); + }); + } +}; diff --git a/database/migrations/2025_12_27_130512_add_photo_rating_config_settings.php b/database/migrations/2025_12_27_130512_add_photo_rating_config_settings.php new file mode 100644 index 00000000000..d3ad44d3d67 --- /dev/null +++ b/database/migrations/2025_12_27_130512_add_photo_rating_config_settings.php @@ -0,0 +1,158 @@ + + */ + public function getConfigs(): array + { + return [ + [ + 'key' => 'rating_enabled', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'is_secret' => false, + 'description' => 'Enable photo rating', + 'details' => 'Master switch to enable or disable the photo rating feature entirely', + 'level' => 0, + 'not_on_docker' => false, + 'order' => 1, + 'is_expert' => false, + ], + [ + 'key' => 'rating_public', + 'value' => '0', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'is_secret' => false, + 'description' => 'Make photo ratings public', + 'details' => 'Allow all users (including non-logged-in visitors) to see photo ratings', + 'level' => 1, + 'not_on_docker' => false, + 'order' => 2, + 'is_expert' => false, + ], + [ + 'key' => 'rating_show_only_when_user_rated', + 'value' => '0', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'is_secret' => false, + 'description' => 'Show ratings only after user has rated', + 'details' => 'Only display ratings (user or average) after the user has submitted their own rating', + 'level' => 1, + 'not_on_docker' => false, + 'order' => 3, + 'is_expert' => false, + ], + [ + 'key' => 'rating_show_avg_in_details', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'is_secret' => false, + 'description' => 'Show average rating in photo details drawer', + 'details' => 'Display average rating and rating count in the photo details sidebar instead of user rating', + 'level' => 0, + 'not_on_docker' => false, + 'order' => 4, + 'is_expert' => false, + ], + [ + 'key' => 'rating_photo_view_mode', + 'value' => 'hover', + 'cat' => self::CAT, + 'type_range' => 'always|hover|never', + 'is_secret' => false, + 'description' => 'Show rating overlay in full photo view', + 'details' => 'Controls visibility of rating overlay: always visible, on hover, or never', + 'level' => 1, + 'not_on_docker' => false, + 'order' => 5, + 'is_expert' => false, + ], + [ + 'key' => 'rating_show_avg_in_photo_view', + 'value' => '0', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'is_secret' => false, + 'description' => 'Display average rating in full photo view', + 'details' => 'Display average rating when viewing a photo in full-size mode instead of the user rating', + 'level' => 1, + 'not_on_docker' => false, + 'order' => 6, + 'is_expert' => false, + ], + [ + 'key' => 'rating_album_view_mode', + 'value' => 'hover', + 'cat' => self::CAT, + 'type_range' => 'always|hover|never', + 'is_secret' => false, + 'description' => 'Show rating on photo thumbnails in album view.', + 'details' => 'Controls visibility of rating on thumbnails: always visible, on hover, or never', + 'level' => 1, + 'not_on_docker' => false, + 'order' => 7, + 'is_expert' => false, + ], + [ + 'key' => 'rating_show_avg_in_album_view', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'is_secret' => false, + 'description' => 'Display average rating on photo thumbnails', + 'details' => 'Display average rating on photo thumbnails in album view instead of the user rating', + 'level' => 0, + 'not_on_docker' => false, + 'order' => 8, + 'is_expert' => false, + ], + ]; + } + + /** + * Run the migrations. + */ + public function up(): void + { + // No mercy + $this->down(); + + DB::table('config_categories')->insert([ + [ + 'cat' => self::CAT, + 'name' => 'Photo star rating', + 'description' => 'This modules enable rating of photos. The user can set a rating from 1 to 5 stars per photo. The average rating is displayed where configured.', + 'order' => 24, + ], + ]); + + DB::table('configs')->insert($this->getConfigs()); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $keys = collect($this->getConfigs())->map(fn ($v) => $v['key'])->all(); + DB::table('configs')->whereIn('key', $keys)->delete(); + DB::table('config_categories')->where('cat', self::CAT)->delete(); + } +}; diff --git a/docs/specs/3-reference/coding-conventions.md b/docs/specs/3-reference/coding-conventions.md index cdce9972d7e..e8bd82ccb86 100644 --- a/docs/specs/3-reference/coding-conventions.md +++ b/docs/specs/3-reference/coding-conventions.md @@ -143,6 +143,27 @@ When dealing with monetary values: $price = 10.99; // Float - prone to rounding errors ``` +### Database Transactions + +- **Preferred:** Use `DB::transaction(callable)` for database transactions instead of manually calling `DB::beginTransaction()`, `DB::commit()`, and `DB::rollback()`. This ensures that transactions are handled more cleanly and reduces the risk of forgetting to commit or rollback. + +```php +// ✅ Correct +DB::transaction(function () { + // Perform database operations +}); + +// ❌ Incorrect +DB::beginTransaction(); +try { + // Perform database operations + DB::commit(); +} catch (Exception $e) { + DB::rollback(); + throw $e; +} +``` + ## Vue3/TypeScript Conventions ### Component Structure @@ -172,6 +193,12 @@ When dealing with monetary values: - **Composition API:** Use TypeScript with Composition API for Vue3. +- **Type generation:** TypeScript types for PHP resources are automatically generated. After modifying PHP resource classes (e.g., `PhotoResource`, `PhotoStatisticsResource`), run: + ```bash + php artisan typescript:transform + ``` + This generates TypeScript definitions in `resources/js/lychee.d.ts` from PHP DTOs, resources, and enums. The generated types are automatically available in the `App.*` namespace (e.g., `App.Http.Resources.Models.PhotoResource`). + - **Function declarations:** Use regular function declarations, not arrow functions. ```typescript // ✅ Correct @@ -297,4 +324,4 @@ Before committing frontend changes: --- -*Last updated: December 21, 2025* +*Last updated: December 27, 2025* diff --git a/docs/specs/3-reference/database-schema.md b/docs/specs/3-reference/database-schema.md index 7b7071282e2..0a627a6c94f 100644 --- a/docs/specs/3-reference/database-schema.md +++ b/docs/specs/3-reference/database-schema.md @@ -26,6 +26,7 @@ System users with authentication and ownership relationships. **Relationships:** - Has many `Album` (owned albums) - Has many `Photo` (owned photos) +- Has many `PhotoRating` - Belongs to `UserGroup` (SE edition) - Has many `OauthCredential` @@ -97,6 +98,8 @@ Individual photos with metadata, EXIF data, and file information. - Has many `SizeVariant` - Has one `Palette` - Has many `Tag` through `photo_tag` pivot table +- Has many `PhotoRating` +- Has one `PhotoStatistics` (virtual relationship for aggregated metrics) #### SizeVariant Different size versions of photos (original, medium, small, thumb). @@ -123,6 +126,25 @@ Color palette information extracted from photos. **Relationships:** - Belongs to `Photo` +#### PhotoRating +User ratings for photos on a 1-5 star scale. + +**Key Fields:** +- `photo_id`: Foreign key to Photo +- `user_id`: Foreign key to User +- `rating`: Integer rating value (1-5) + +**Unique Constraint:** +- Composite unique index on (`photo_id`, `user_id`) - one rating per user per photo + +**Relationships:** +- Belongs to `Photo` +- Belongs to `User` + +**Statistics:** +- Average rating and count are computed via `PhotoStatistics` model +- Current user's rating is exposed through `PhotoResource` + ### Configuration and System Models #### Configs diff --git a/docs/specs/4-architecture/features/001-photo-star-rating/plan.md b/docs/specs/4-architecture/features/001-photo-star-rating/plan.md new file mode 100644 index 00000000000..747914b89c7 --- /dev/null +++ b/docs/specs/4-architecture/features/001-photo-star-rating/plan.md @@ -0,0 +1,948 @@ +# Feature Plan 001 – Photo Star Rating + +_Linked specification:_ `docs/specs/4-architecture/features/001-photo-star-rating/spec.md` +_Status:_ Draft +_Last updated:_ 2025-12-27 + +> Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant, log any new high- or medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), and assume clarifications are resolved only when the spec's normative sections (requirements/NFR/behaviour/telemetry) and, where applicable, ADRs under `docs/specs/5-decisions/` have been updated. + +## Key Implementation Patterns (from Resolved Questions) + +This section summarizes critical implementation decisions from the 25 resolved open questions (Q001-01 through Q001-25): + +**Backend Patterns:** +- **Q001-07:** Statistics record creation using `firstOrCreate()` in transaction (atomic, no race conditions) +- **Q001-08:** Return 409 Conflict on transaction failures (distinguishes DB errors from validation) +- **Q001-06:** Return 200 OK for idempotent rating removal (rating=0 on non-existent rating) +- **Q001-05:** Read access authorization (anyone who can view can rate, not write-only) +- **Q001-09:** Eager load user ratings with closure to prevent N+1: `$photos->load(['ratings' => fn($q) => $q->where('user_id', auth()->id())])` +- **Q001-11:** Independent `ratings_enabled` master switch (separate from `metrics_enabled`) + +**Frontend Patterns:** +- **Q001-13:** Use PrimeVue half-star icons: pi-star, pi-star-fill, pi-star-half, pi-star-half-fill +- **Q001-10:** Loading state pattern - disable all star buttons during API call (set `loading = true`) +- **Q001-17:** Wait for server response (no optimistic updates) +- **Q001-14:** Persist current state while loading (don't clear selection immediately) +- **Q001-15:** No tooltips on star buttons +- **Q001-16:** Defer accessibility enhancements (basic ARIA only) +- **Cumulative star display:** Rating N shows stars 1 through N filled (e.g., rating 3 = ★★★☆☆, not just star 3) + +**UI Overlay Behavior:** +- **Q001-01:** Bottom-center positioning for PhotoRatingOverlay on full-size photo +- **Q001-02:** 3-second auto-hide timer (clearable on mouse enter, restarts on mouse leave) +- **Q001-04:** Desktop-only overlays (hidden on mobile below md: breakpoint) +- **Q001-18:** Always show overlay when visible (no "show only when hovering stars" logic) +- **Q001-20:** Minimal implementation (basic hover + auto-hide, no advanced features) + +**Configuration & Settings:** +- **Q001-11:** 6 independent config settings stored in `configs` database table (not Laravel config files) +- **Q001-12:** When `metrics_enabled = false`, hide all rating UI +- **Q001-25:** Sensible defaults, no backfill migration needed + +**Deferred Features:** +- **Q001-19:** No telemetry events or analytics +- **Q001-21:** Album aggregate ratings (defer) +- **Q001-22:** No rating export/import +- **Q001-23:** No rating notifications +- **Q001-24:** No recalculation command (trust transactions) + +## Vision & Success Criteria + +**User Value:** Logged-in users can rate photos 1-5 stars, see aggregate ratings from the community, and manage their own ratings through an intuitive interface at the bottom of the photo view. + +**Success Signals:** +- Users can rate, update, and remove ratings via API and UI +- Average rating and vote count display correctly in photo details +- User's current rating is pre-selected when viewing photo +- All rating operations complete in <500ms (p95) +- Zero data integrity issues (no orphaned ratings, correct statistics) +- 100% test coverage for rating paths (unit + feature + component) + +**Quality Bars:** +- NFR-001-01: Atomic database updates (transactions) +- NFR-001-05: PHP conventions (license headers, snake_case, strict comparison, PSR-4) +- NFR-001-06: Vue3/TypeScript conventions (Composition API, .then() pattern, services pattern) +- NFR-001-07: Full test coverage (all scenarios from spec) + +## Scope Alignment + +**In scope:** +- PhotoRating model and migration (photo_ratings table) +- Statistics table enhancement (rating_sum, rating_count columns) +- POST `/Photo::rate` endpoint (create/update/remove ratings) +- PhotoResource enhancement (rating_avg, rating_count, user_rating fields) +- PhotoRatingWidget Vue component (star selector UI for details drawer) +- ThumbRatingOverlay Vue component (hover overlay on thumbnails) +- PhotoRatingOverlay Vue component (hover overlay on full-size photo) +- Integration into PhotoDetails drawer +- Integration into PhotoThumb component +- Integration into PhotoPanel component +- photo-service.ts rating method +- Full test suite (unit, feature, component) +- Database indexes for performance +- Atomic transaction logic for data integrity +- Hover detection and auto-hide logic +- Store setting respect (display_thumb_photo_overlay) +- **6 new config settings** (FR-001-11 through FR-001-16): + - `rating_show_avg_in_details` (bool, default: true) + - `rating_show_avg_in_photo_view` (bool, default: true) + - `rating_photo_view_mode` (enum: always|hover|hidden, default: hover) + - `rating_show_avg_in_album_view` (bool, default: true) + - `rating_album_view_mode` (enum: always|hover|hidden, default: hover) + - `ratings_enabled` (bool, default: true) - master switch for rating functionality + +**Out of scope:** +- Rating other entities (albums, tags, etc.) - only photos +- Anonymous ratings - authentication required +- Rating history/audit trail - only current state +- Public display of individual user ratings - aggregate only +- Rating notifications or activity feeds +- Advanced analytics or trending ratings +- Album-level aggregate ratings +- Rating export/import functionality (Q001-22 → Option C: no export) +- Accessibility enhancements beyond basic ARIA labels (Q001-16 → Option C: defer) +- Album aggregate ratings (Q001-21 → Option A: defer) +- Rating notifications (Q001-23 → Option A: defer) +- Recalculation command (Q001-24 → Option B: not needed) +- Write access authorization (using read access per Q001-05) + +## Dependencies & Interfaces + +**Backend Dependencies:** +- Photo model (`app/Models/Photo.php`) +- Statistics model (`app/Models/Statistics.php`) +- User model (`app/Models/User.php`) +- PhotoController (`app/Http/Controllers/PhotoController.php`) +- PhotoResource (`app/Http/Resources/Models/PhotoResource.php`) +- Existing authorization traits/middleware (login_required:album) +- Laravel migrations and schema builder +- Database transaction support + +**Frontend Dependencies:** +- PhotoDetails.vue component (`resources/js/components/drawers/PhotoDetails.vue`) +- photo-service.ts (`resources/js/services/photo-service.ts`) +- PrimeVue components (Button, Rating, or custom star component) +- Toast notification system (existing) +- Constants utility for API URL + +**Tooling:** +- php-cs-fixer (PHP code style) +- PHPStan level 6 (static analysis) +- phpunit (testing) +- npm run format (Prettier) +- npm run check (frontend tests) + +**Contracts:** +- PhotoResource API response schema +- OpenAPI/API documentation (to be updated) + +## Assumptions & Risks + +**Assumptions:** +1. Photo model uses 24-char random string IDs (verified from exploration) +2. Statistics table already exists with foreign key to photos +3. User authentication system is working and provides $this->user in controllers +4. Existing authorization patterns (photo access control) can be reused +5. PrimeVue or custom star rating component is acceptable for UI +6. Database supports transactions and foreign key constraints +7. Metrics system (metrics_enabled config, CAN_READ_METRICS permission) already works + +**Risks & Mitigations:** + +| Risk | Impact | Mitigation | +|------|--------|-----------| +| Race conditions on concurrent ratings | High - data corruption | Use DB transactions wrapping both photo_ratings and statistics updates. Add unique constraint on (photo_id, user_id). Test with concurrent requests. | +| Performance degradation with many ratings | Medium - slow UI | Add database indexes on photo_id and user_id in photo_ratings. Denormalize statistics (already planned). Monitor query performance during testing. | +| Statistics table doesn't exist for some photos | Medium - errors | Check for statistics record existence, create if missing during first rating (or ensure cascade creation). | +| Frontend component complexity | Low - dev time | Reuse existing PrimeVue Rating component or build simple custom component. Start with basic implementation, enhance UI later if needed. | +| Migration rollback complexity | Low - deployment | Ensure migration down() properly drops columns and table. Test rollback in local environment. | + +## Implementation Drift Gate + +**Execution Plan:** +1. After completing each increment, verify: + - All tests pass (`php artisan test`, `npm run check`) + - PHPStan passes (`make phpstan`) + - Code style passes (`vendor/bin/php-cs-fixer fix --dry-run`, `npm run format`) + - Manual smoke test in UI (if UI increment) +2. Record drift findings in this section with date and resolution +3. Update spec.md if requirements change during implementation + +**Commands to Rerun:** +```bash +# Full quality gate +vendor/bin/php-cs-fixer fix +npm run format +php artisan test +npm run check +make phpstan +``` + +**Drift Log:** +_To be populated during implementation_ + +## Increment Map + +### **I1 – Database Schema & Migrations** (≤60 min) + +- **Goal:** Create photo_ratings table and add rating columns to photo_statistics table +- **Preconditions:** None (foundational increment) +- **Scenarios:** Foundation for all scenarios +- **Steps:** + 1. Create migration: `create_photo_ratings_table` + - Columns: id, photo_id (char 24, FK), user_id (int, FK), rating (tinyint 1-5), timestamps + - Unique constraint: (photo_id, user_id) + - Foreign keys with CASCADE delete + - Indexes on photo_id and user_id + 2. Create migration: `add_rating_columns_to_photo_statistics` + - Add rating_sum (BIGINT UNSIGNED, default 0) + - Add rating_count (INT UNSIGNED, default 0) + 3. Test migrations run successfully (up and down) +- **Commands:** + ```bash + php artisan make:migration create_photo_ratings_table + php artisan make:migration add_rating_columns_to_photo_statistics + php artisan migrate + php artisan migrate:rollback --step=2 + php artisan migrate + ``` +- **Exit:** Migrations run cleanly, tables created with correct schema, rollback works + +--- + +### **I2 – PhotoRating Model & Relationships** (≤60 min) + +- **Goal:** Create PhotoRating model with relationships and validation +- **Preconditions:** I1 complete (database schema exists) +- **Scenarios:** Foundation for S-001-01 through S-001-15 +- **Steps:** + 1. Write unit test: `tests/Unit/Models/PhotoRatingTest.php` + - Test belongsTo Photo relationship + - Test belongsTo User relationship + - Test rating attribute casting (integer) + - Test validation (rating must be 1-5) + 2. Create model: `app/Models/PhotoRating.php` + - License header + - Table name: photo_ratings + - Fillable: photo_id, user_id, rating + - Casts: rating => integer, timestamps => UTC + - Relationships: belongsTo Photo, belongsTo User + - No incrementing (uses auto-increment id) + 3. Update Photo model: add hasMany PhotoRatings relationship + 4. Update User model: add hasMany PhotoRatings relationship (optional, for future use) + 5. Run tests +- **Commands:** + ```bash + php artisan test tests/Unit/Models/PhotoRatingTest.php + make phpstan + ``` +- **Exit:** All tests green, relationships work, PHPStan passes + +--- + +### **I3 – Statistics Model Enhancement** (≤45 min) + +- **Goal:** Add rating aggregation logic to Statistics model +- **Preconditions:** I1 complete (rating columns exist) +- **Scenarios:** Foundation for displaying ratings +- **Steps:** + 1. Write unit test: `tests/Unit/Models/StatisticsTest.php` (or extend existing) + - Test rating_avg accessor (sum / count when count > 0, else null) + - Test rating_sum and rating_count attributes + 2. Update Statistics model: `app/Models/Statistics.php` + - Add rating_sum and rating_count to fillable/casts + - Add accessor for rating_avg: `getRatingAvgAttribute()` returns decimal(3,2) or null + - Cast rating_sum as integer, rating_count as integer + 3. Run tests +- **Commands:** + ```bash + php artisan test tests/Unit/Models/StatisticsTest.php + make phpstan + ``` +- **Exit:** rating_avg calculation works correctly, all tests green + +--- + +### **I4 – SetPhotoRatingRequest Validation** (≤60 min) + +- **Goal:** Create request validation class for rating endpoint +- **Preconditions:** None (can run in parallel with I2/I3) +- **Scenarios:** S-001-09 (validation), S-001-07 (unauthenticated), S-001-08 (unauthorized) +- **Steps:** + 1. Write feature test: `tests/Feature_v2/Photo/SetPhotoRatingRequestTest.php` + - Test rating validation: must be 0-5 + - Test rating must be integer (not string, float) + - Test photo_id required and exists + - Test authentication required + - Test authorization (user has photo access) + 2. Create request class: `app/Http/Requests/Photo/SetPhotoRatingRequest.php` + - License header + - Rules: photo_id (required, exists:photos,id), rating (required, integer, min:0, max:5) + - Authorize: user must have access to photo (reuse existing photo authorization logic) + - Use HasPhotoTrait if appropriate + 3. Run tests +- **Commands:** + ```bash + php artisan test tests/Feature_v2/Photo/SetPhotoRatingRequestTest.php + make phpstan + ``` +- **Exit:** Validation works, all test scenarios pass + +--- + +### **I5 – PhotoController::rate Method (Core Logic)** (≤90 min) + +- **Goal:** Implement rating endpoint with atomic database updates +- **Preconditions:** I1, I2, I3, I4 complete +- **Scenarios:** S-001-01, S-001-02, S-001-03, S-001-06 (atomic updates) +- **Steps:** + 1. Write feature test: `tests/Feature_v2/Photo/PhotoRatingTest.php` + - Test POST /Photo::rate creates new rating (S-001-01) + - Test POST /Photo::rate updates existing rating (S-001-02) + - Test POST /Photo::rate with rating=0 removes rating (S-001-03) + - Test statistics updated correctly (sum and count) + - Test response includes updated PhotoResource + - Test idempotent removal (S-001-14) - returns 200 OK when removing non-existent rating (Q001-06) + - Test 409 Conflict on transaction failure (Q001-08) + 2. Implement `PhotoController::rate()` method + - Accept SetPhotoRatingRequest + - Wrap in DB::transaction with 409 Conflict error handling (Q001-08 → Option B) + - Ensure statistics record exists using firstOrCreate (Q001-07 → Option A): + ```php + $statistics = PhotoStatistics::firstOrCreate( + ['photo_id' => $photo_id], + ['rating_sum' => 0, 'rating_count' => 0] + ); + ``` + - If rating > 0: + - Upsert PhotoRating (updateOrCreate by photo_id + user_id) + - Get old rating if exists + - Update statistics: adjust sum (subtract old, add new), increment count if new + - If rating == 0: + - Find and delete PhotoRating + - Update statistics: subtract rating from sum, decrement count + - Return 200 OK (idempotent removal per Q001-06) + - Return PhotoResource + - On transaction failure: catch exception, return 409 Conflict + 3. Add route in `routes/api_v2.php`: `Route::post('/Photo::rate', [PhotoController::class, 'rate'])->middleware('login_required:album')` + 4. Run tests +- **Commands:** + ```bash + php artisan test tests/Feature_v2/Photo/PhotoRatingTest.php + make phpstan + vendor/bin/php-cs-fixer fix app/Http/Controllers/PhotoController.php + ``` +- **Exit:** All rating scenarios work, atomic updates verified, tests green, PHPStan passes + +--- + +### **I6 – PhotoResource Enhancement** (≤60 min) + +- **Goal:** Add rating data to PhotoResource serialization +- **Preconditions:** I3, I5 complete (statistics and rating logic exist) +- **Scenarios:** S-001-11, S-001-12, S-001-13, S-001-15 (display scenarios) +- **Steps:** + 1. Write feature test: `tests/Feature_v2/Resources/PhotoResourceTest.php` (or extend existing) + - Test PhotoResource includes rating_avg and rating_count when metrics enabled + - Test PhotoResource includes user_rating when user is authenticated + - Test user_rating is null when user hasn't rated + - Test user_rating reflects user's actual rating + - Test rating fields omitted when metrics disabled + 2. Update PhotoResource: `app/Http/Resources/Models/PhotoResource.php` + - Add to statistics section (when metrics enabled): + - `rating_avg` => $this->statistics?->rating_avg (decimal, nullable) + - `rating_count` => $this->statistics?->rating_count ?? 0 + - Add at top level (when user authenticated): + - `user_rating` => $this->ratings()->where('user_id', auth()->id())->value('rating') + 3. Update PhotoController methods that return PhotoResource to eager load ratings for current user (Q001-09 → Option A): + ```php + // Eager load user's rating to prevent N+1 queries + $photos->load(['ratings' => fn($q) => $q->where('user_id', auth()->id())]); + ``` + 4. Run tests +- **Commands:** + ```bash + php artisan test tests/Feature_v2/Resources/PhotoResourceTest.php + make phpstan + ``` +- **Exit:** PhotoResource includes all rating fields correctly, tests pass + +--- + +### **I7 – Frontend Service Layer** (≤45 min) + +- **Goal:** Add rating method to photo-service.ts +- **Preconditions:** I5 complete (API endpoint exists) +- **Scenarios:** Foundation for all UI scenarios +- **Steps:** + 1. Update `resources/js/services/photo-service.ts` + - Add method: `setRating(photo_id: string, rating: 0 | 1 | 2 | 3 | 4 | 5, album_id?: string | null): Promise>` + - Implementation: `return axios.post(\`\${Constants.getApiUrl()}/Photo::rate\`, { photo_id, rating })` + 2. Update TypeScript PhotoResource interface (if separate file) + - Add rating_avg?: number (nullable) + - Add rating_count: number + - Add user_rating?: number (nullable, 1-5) + 3. Write basic service test (if testing infrastructure exists) +- **Commands:** + ```bash + npm run check + npm run format + ``` +- **Exit:** Service method compiles, types are correct, format passes + +--- + +### **I8 – PhotoRatingWidget Component (for Details Drawer)** (≤90 min) + +- **Goal:** Create star rating widget for PhotoDetails drawer +- **Preconditions:** I7 complete (service exists) +- **Scenarios:** UI-001-01 through UI-001-08 +- **Steps:** + 1. Create component: `resources/js/components/PhotoRatingWidget.vue` + - Props: photo_id (string), initial_rating (number | null), rating_avg (number | null), rating_count (number) + - State: selected_rating (ref), hover_rating (ref), loading (ref) + - Template: + - Display average rating and count (e.g., "★★★★☆ 4.2 (15 votes)") + - Use PrimeVue half-star icons (Q001-13 → Option B): + - pi-star (empty) + - pi-star-fill (full) + - pi-star-half (half outline) + - pi-star-half-fill (half filled) + - Display "Your rating:" label + - Render buttons 0-5 with star icons (0 = ×, 1-5 = ☆/★) + - **CUMULATIVE star display:** rating N shows stars 1-N filled (e.g., rating 3 = ★★★☆☆) + - Highlight selected rating with filled stars (cumulative) + - Show hover preview with filled stars 1 through hover value (cumulative) + - No tooltips needed (Q001-15 → Option C) + - Disable buttons when loading or not logged in (Q001-10 → Option A) + - Methods: + - `handleRatingClick(rating: number)`: + - Set loading = true, disable all star buttons (Q001-10) + - Call photoService.setRating() + - Wait for server response (no optimistic updates per Q001-17 → Option A) + - Update on success, show toast + - Clear loading state + - `handleMouseEnter(rating: number)`: set hover_rating + - `handleMouseLeave()`: clear hover_rating + - Style: Use PrimeVue icons (pi-star, pi-star-fill, pi-star-half, pi-star-half-fill) + 2. Write component test (if testing infrastructure exists) + - Test buttons render + - Test click handler calls service + - Test loading state + - Test disabled state + 3. Implement toast notifications (success/error) +- **Commands:** + ```bash + npm run check + npm run format + ``` +- **Exit:** Component renders, handles clicks, shows loading/success/error states + +--- + +### **I9 – Integrate PhotoRatingWidget into PhotoDetails** (≤60 min) + +- **Goal:** Add rating widget to photo details drawer +- **Preconditions:** I6, I8 complete (PhotoResource has rating data, widget component exists) +- **Scenarios:** S-001-11, S-001-12, S-001-13, UI-001-01 through UI-001-08 +- **Steps:** + 1. Update `resources/js/components/drawers/PhotoDetails.vue` + - Import PhotoRatingWidget component + - Add section below statistics (or appropriate location per mockup) + - Pass props: photo_id, user_rating, rating_avg, rating_count from photo resource + - Handle rating update event (refresh photo data or optimistically update) + 2. Manual smoke test in browser: + - View photo without rating → see "No ratings yet" + - Rate photo → see average update, your rating selected + - Change rating → see average recalculate + - Remove rating (click 0) → see average update, selection cleared + - Verify statistics section displays correctly + 3. Test edge cases: + - Not logged in → buttons disabled, tooltip shown + - Photo with no ratings → displays correctly + - Photo with many ratings → displays correctly +- **Commands:** + ```bash + npm run check + npm run format + npm run dev # Start dev server for manual testing + ``` +- **Exit:** Rating widget displays correctly in PhotoDetails, all interactions work + +--- + +### **I9a – ThumbRatingOverlay Component (for Thumbnails)** (≤90 min) + +- **Goal:** Create rating overlay component that appears on photo thumbnail hover +- **Preconditions:** I7 complete (service exists), I8 complete (rating widget pattern established) +- **Scenarios:** S-001-16, S-001-17, S-001-19, UI-001-09, UI-001-10, UI-001-13 +- **Steps:** + 1. Create component: `resources/js/components/gallery/albumModule/thumbs/ThumbRatingOverlay.vue` + - Props: photo (PhotoResource), compact (boolean, default true) + - State: hover_rating (ref), loading (ref) + - Template structure: + - Container div with gradient background: `bg-linear-to-t from-[#00000099]` + - Average rating display: "★★★★☆ 4.2 (15)" (compact format) + - Use PrimeVue half-star icons (Q001-13 → Option B): pi-star, pi-star-fill, pi-star-half, pi-star-half-fill + - Interactive stars: compact horizontal layout + - Loading indicator overlay + - CSS classes: + - Position: absolute, bottom-0, full-width + - Visibility: `opacity-0 group-hover:opacity-100 transition-all ease-out` + - Mobile hide: `hidden md:block` (only desktop per Q001-04) + - Gradient padding for text readability + - Methods: + - `handleRatingClick(rating: number)`: + - Set loading = true, disable all star buttons (Q001-10) + - Call photoService.setRating() + - Wait for server response (no optimistic updates per Q001-17) + - Emit 'rated' when successful + - `handleStarHover(rating: number)`: preview stars + - No tooltips needed (Q001-15 → Option C) + - Pattern reference: ThumbFavourite.vue (existing hover button pattern) + 2. Implement compact star design: + - Smaller star icons (text-sm or custom sizing) + - Horizontal layout with cumulative display + - **CUMULATIVE visualization:** rating 3 = "★★★☆☆" (stars 1-3 filled, not just star 3) + - Click target: minimum 24px (w-6) for touch accessibility + 3. Test component isolation: + - Render with various rating states + - Test hover transitions + - Test click propagation (stop propagation to prevent thumbnail click) + - Test loading state disables buttons (Q001-10) +- **Commands:** + ```bash + npm run check + npm run format + ``` +- **Exit:** ThumbRatingOverlay component works in isolation + +--- + +### **I9b – Integrate ThumbRatingOverlay into PhotoThumb** (≤60 min) + +- **Goal:** Add rating overlay to photo thumbnails in album grid +- **Preconditions:** I9a complete (ThumbRatingOverlay exists) +- **Scenarios:** S-001-16, S-001-17, S-001-19 +- **Steps:** + 1. Update `resources/js/components/gallery/albumModule/thumbs/PhotoThumb.vue` + - Import ThumbRatingOverlay component + - Add after existing overlay section (after line ~75, before video play icon) + - Position at bottom of thumbnail (absolute, below metadata overlay) + - Pass photo prop with rating data + - Respect `display_thumb_photo_overlay` store setting (same as metadata overlay) + - Handle 'rated' event: refresh photo data or optimistically update + 2. Test overlay stacking: + - Ensure rating overlay doesn't conflict with metadata overlay + - Verify z-index layering (rating overlay should be above metadata) + - Test with various thumbnail sizes + 3. Test store setting integration: + - `display_thumb_photo_overlay === 'hover'` → show on hover only + - `display_thumb_photo_overlay === 'always'` → always visible + - `display_thumb_photo_overlay === 'never'` → hidden + 4. Manual smoke test: + - Hover over thumbnail → rating overlay appears + - Click star → photo rated, toast confirms, overlay updates + - Thumbnail click (non-overlay area) → still opens photo +- **Commands:** + ```bash + npm run check + npm run format + npm run dev # Manual testing + ``` +- **Exit:** Rating overlay displays on thumbnails, respects settings, interactions work + +--- + +### **I9c – PhotoRatingOverlay Component (for Full-Size Photo)** (≤90 min) + +- **Goal:** Create rating overlay for full-size photo view (hover on lower area) +- **Preconditions:** I7, I8 complete (service and widget pattern exist) +- **Scenarios:** S-001-18, S-001-20, UI-001-11, UI-001-12 +- **Steps:** + 1. Create component: `resources/js/components/gallery/photoModule/PhotoRatingOverlay.vue` + - Props: photo_id (string), rating_avg (number | null), rating_count (number), user_rating (number | null) + - State: visible (ref), hover_rating (ref), loading (ref), auto_hide_timer (ref) + - Template: + - Container with semi-transparent background + - Horizontal compact layout with cumulative stars: "[0][1][2][3][4][5] ★★★★☆ 4.2 (15) Your rating: ★★★★☆" + - Use PrimeVue half-star icons (Q001-13 → Option B): pi-star, pi-star-fill, pi-star-half, pi-star-half-fill + - **CUMULATIVE display:** user rating 4 shows "★★★★☆" (stars 1-4 filled) + - **Inline [0] button** for rating removal (shown as "×") + - **Positioned bottom-center** (horizontally centered, above metadata overlay per Q001-01) + - No tooltips needed (Q001-15 → Option C) + - Visibility logic: + - Show when parent emits 'hover-lower-area' event + - **Auto-hide after 3 seconds** of inactivity (Q001-02 → Option A: 3 seconds) + - Persist while mouse is over overlay itself (cancels auto-hide timer) + - Fade transition: opacity 0 → 100 + - **Desktop-only:** Hidden on mobile below md: breakpoint (Q001-04 → Option A) + - Always show overlay when visible (Q001-18 → Option A: no "show only when hovering stars" logic) + - Methods: + - `show()`: make visible, start auto-hide timer + - `hide()`: fade out + - `resetAutoHideTimer()`: clear and restart 3s timer + - `handleMouseEnter()`: cancel auto-hide + - `handleMouseLeave()`: restart auto-hide + - `handleRatingClick(rating)`: + - Set loading = true, disable all star buttons (Q001-10) + - Call photoService.setRating() + - Wait for server response (no optimistic updates per Q001-17) + - Show toast on success + - Clear loading state + 2. Implement auto-hide behavior (Q001-02 → 3 seconds): + - Use setTimeout for 3-second delay + - Clear timeout on mouse enter (cancel auto-hide) + - Restart timeout on mouse leave (resume auto-hide) + 3. Style for readability: + - Gradient background or solid semi-transparent background + - Text shadow for visibility on any photo + - z-index above photo, below Dock and metadata Overlay +- **Commands:** + ```bash + npm run check + npm run format + ``` +- **Exit:** PhotoRatingOverlay component works in isolation + +--- + +### **I9d – Integrate PhotoRatingOverlay into PhotoPanel** (≤60 min) + +- **Goal:** Add hover-triggered rating overlay to full-size photo view +- **Preconditions:** I9c complete (PhotoRatingOverlay exists) +- **Scenarios:** S-001-18, S-001-20, UI-001-11, UI-001-12 +- **Steps:** + 1. Update `resources/js/components/gallery/photoModule/PhotoPanel.vue` + - Import PhotoRatingOverlay component + - Add hover detection zone: + - Div covering lower 20-30% of photo area (desktop-only, md: breakpoint) + - On mouseenter → show overlay + - On mouseleave → start auto-hide timer + - **Position overlay bottom-center** (Q001-01 → Option A) + - Pass photo rating props from photoStore + - Handle overlay 'rated' event: refresh photo data + 2. OR update `resources/js/components/gallery/photoModule/PhotoBox.vue` (alternative approach): + - Add hover zone to photo element itself + - Emit 'hover-lower-area' event when mouse in lower portion + - Parent (PhotoPanel) shows PhotoRatingOverlay + 3. Test positioning: + - Ensure overlay doesn't block Dock buttons + - Ensure overlay doesn't block metadata Overlay + - Test with different photo aspect ratios + - Test with different screen sizes + 4. Test auto-hide behavior (Q001-02 → 3 seconds): + - Hover lower area → overlay appears + - Wait 3 seconds → overlay fades out + - Hover over overlay → auto-hide cancelled (timer cleared) + - Move mouse away from overlay → auto-hide restarts (3s timer) + - Test on mobile → overlay hidden (Q001-04 → desktop-only) + 5. Manual smoke test: + - View full-size photo, hover lower area → overlay appears + - Click star → photo rated, toast confirms + - Overlay auto-hides after inactivity +- **Commands:** + ```bash + npm run check + npm run format + npm run dev # Manual testing + ``` +- **Exit:** Rating overlay displays on full-size photo hover, auto-hide works + +--- + +### **I10 – Error Handling & Edge Cases** (≤60 min) + +- **Goal:** Handle error scenarios and edge cases +- **Preconditions:** I5, I9 complete (API and UI exist) +- **Scenarios:** S-001-07 (unauthenticated), S-001-08 (unauthorized), S-001-09 (validation), S-001-10 (not found), S-001-14 (idempotent removal) +- **Steps:** + 1. Write feature tests for error scenarios: + - POST /Photo::rate without auth → 401 + - POST /Photo::rate without photo access → 403 + - POST /Photo::rate with invalid rating (6, -1, "abc") → 422 + - POST /Photo::rate with non-existent photo_id → 404 + 2. Verify frontend error handling: + - Network error → show error toast + - 401/403/404/422 → show appropriate error message + - Loading state clears on error + 3. Test statistics edge case: + - Photo without statistics record → create on first rating + - Rating removal when count=1 → avg becomes null, count becomes 0 + 4. Run full test suite +- **Commands:** + ```bash + php artisan test tests/Feature_v2/Photo/PhotoRatingTest.php + npm run check + ``` +- **Exit:** All error scenarios handled gracefully, tests pass + +--- + +### **I11 – Concurrency & Data Integrity Tests** (≤60 min) + +- **Goal:** Verify atomic updates under concurrent load +- **Preconditions:** I5 complete (transaction logic exists) +- **Scenarios:** S-001-05, S-001-06 (concurrent updates) +- **Steps:** + 1. Write concurrency test: `tests/Feature_v2/Photo/PhotoRatingConcurrencyTest.php` + - Test scenario: Same user updates rating rapidly (last write wins, no duplicates) + - Test scenario: Multiple users rate same photo concurrently (all succeed, correct final count) + - Use parallel requests or database transaction simulation + 2. Verify unique constraint prevents duplicate records + 3. Verify statistics sum and count remain consistent + 4. Run tests multiple times to catch race conditions +- **Commands:** + ```bash + php artisan test tests/Feature_v2/Photo/PhotoRatingConcurrencyTest.php --repeat=10 + make phpstan + ``` +- **Exit:** No race conditions, unique constraint enforced, statistics always consistent + +--- + +### **I12 – Documentation & Knowledge Map Updates** (≤45 min) + +- **Goal:** Update project documentation with new feature +- **Preconditions:** All implementation complete +- **Scenarios:** Documentation deliverables from spec +- **Steps:** + 1. Update `docs/specs/4-architecture/knowledge-map.md`: + - Add PhotoRating model + - Add relationships: Photo hasMany PhotoRatings, User hasMany PhotoRatings + - Add Statistics enhancements: rating_sum, rating_count, rating_avg + 2. Update `docs/specs/4-architecture/roadmap.md`: + - Move Feature 001 from Active to Completed + - Record completion date + 3. Update API documentation (if separate file exists): + - Document POST /Photo::rate endpoint + - Document PhotoResource schema changes + 4. Add feature README summary to main README (if appropriate) +- **Commands:** + ```bash + # No specific commands, manual documentation updates + ``` +- **Exit:** All documentation updated and accurate + +--- + +### **I12a – Config Settings for Rating Visibility Control** (≤60 min) + +- **Goal:** Implement 6 config settings to control rating display behavior (FR-001-11 through FR-001-16) +- **Preconditions:** I8, I9a, I9c complete (UI components exist) +- **Scenarios:** FR-001-11 through FR-001-16 +- **Steps:** + 1. **Backend - Add to Configs table:** + - Create migration to add 6 new rows to `configs` table (Q001-11 → Option C: independent setting): + - `ratings_enabled` (type: boolean, value: '1', default: true) - master switch (FR-001-16) + - `rating_show_avg_in_details` (type: boolean, value: '1', default: true) + - `rating_show_avg_in_photo_view` (type: boolean, value: '1', default: true) + - `rating_photo_view_mode` (type: string, value: 'hover', allowed: 'always|hover|hidden') + - `rating_show_avg_in_album_view` (type: boolean, value: '1', default: true) + - `rating_album_view_mode` (type: string, value: 'hover', allowed: 'always|hover|hidden') + - Update Config model/seeder if necessary to include these new keys + - Ensure configs are loaded and accessible via Config facade or service + - Update `/Photo::rate` endpoint to check `ratings_enabled` and return 403 if disabled + 2. **Frontend - Add to Lychee store:** + - Add 6 settings to Lychee store (LycheeState or SettingsState) + - Fetch config values from backend API (likely included in initial config load) + - Define TypeScript types for enum values: `type RatingViewMode = 'always' | 'hover' | 'hidden'` + - Add getters for each setting + 3. **Update components to respect settings:** + - **All components:** Check `ratings_enabled`, don't render if false (FR-001-16) + - **PhotoRatingWidget (I8):** + - Check `rating_show_avg_in_details`, hide average/count if false + - When metrics disabled (Q001-12 → Option B): hide all rating UI + - **ThumbRatingOverlay (I9a):** + - Check `rating_show_avg_in_album_view`, hide average/count if false + - Check `rating_album_view_mode`: + - `always` → no group-hover, always visible + - `hover` → existing group-hover behavior + - `hidden` → don't render component at all + - **PhotoRatingOverlay (I9c):** + - Check `rating_show_avg_in_photo_view`, hide average/count if false + - Check `rating_photo_view_mode`: + - `always` → no auto-hide timer, always visible (Q001-20 → Option B: minimal implementation) + - `hover` → existing hover + auto-hide behavior + - `hidden` → don't render component at all + 4. **Settings UI (optional, can defer):** + - Add UI in settings panel to toggle these 6 settings + - Save to backend config + 5. **Test all combinations:** + - `ratings_enabled = false` → no rating UI anywhere, `/Photo::rate` returns 403 + - Average hidden but selector shown + - Overlay mode set to `always` (no auto-hide) + - Overlay mode set to `hidden` (no rendering) + - `metrics_enabled = false` → no rating UI (per Q001-12) + 6. **Default configuration (Q001-25 → Option A):** + - All 6 settings have sensible defaults (all enabled/hover) + - No backfill migration needed for existing photos +- **Commands:** + ```bash + npm run check + npm run format + php artisan test # If backend config changes + ``` +- **Exit:** All 6 settings implemented and respected by UI components, defaults applied + +--- + +### **I13 – Final Quality Gate & Cleanup** (≤60 min) + +- **Goal:** Run full quality gate and clean up any issues +- **Preconditions:** All increments complete +- **Scenarios:** All scenarios verified +- **Steps:** + 1. Run full PHP quality gate: + - `vendor/bin/php-cs-fixer fix` (apply fixes) + - `php artisan test` (all tests) + - `make phpstan` (static analysis) + 2. Run full frontend quality gate: + - `npm run format` (apply fixes) + - `npm run check` (all tests) + 3. Manual smoke test checklist: + - ✅ Rate photo as logged-in user + - ✅ Update rating + - ✅ Remove rating + - ✅ View photo with ratings from others + - ✅ View photo with no ratings + - ✅ Verify statistics display + - ✅ Verify disabled state when not logged in + - ✅ Verify error handling (invalid rating, network error) + 4. Review code for: + - License headers in all new files + - Consistent naming (snake_case variables, etc.) + - No unused imports or variables + - Comments only where logic isn't self-evident + 5. Record any deferred items in Follow-ups section +- **Commands:** + ```bash + vendor/bin/php-cs-fixer fix + npm run format + php artisan test + npm run check + make phpstan + ``` +- **Exit:** All quality gates pass, feature ready for review/commit + +--- + +## Scenario Tracking + +| Scenario ID | Increment / Task reference | Notes | +|-------------|---------------------------|-------| +| S-001-01 | I5 (PhotoController::rate) | New rating creation | +| S-001-02 | I5 (PhotoController::rate) | Update existing rating | +| S-001-03 | I5 (PhotoController::rate) | Remove rating (rating=0) | +| S-001-04 | I5, I11 (Controller + Concurrency test) | Multiple users rating | +| S-001-05 | I11 (Concurrency test) | Same user concurrent updates | +| S-001-06 | I11 (Concurrency test) | Different users concurrent | +| S-001-07 | I4, I10 (Request validation + Error handling) | Unauthenticated | +| S-001-08 | I4, I10 (Request validation + Error handling) | Unauthorized | +| S-001-09 | I4, I10 (Request validation + Error handling) | Invalid rating | +| S-001-10 | I4, I10 (Request validation + Error handling) | Photo not found | +| S-001-11 | I6, I9 (PhotoResource + UI) | View without rating | +| S-001-12 | I6, I9 (PhotoResource + UI) | View with user rating | +| S-001-13 | I6, I9 (PhotoResource + UI) | No ratings exist | +| S-001-14 | I5, I10 (Controller + Edge cases) | Idempotent removal | +| S-001-15 | I6 (PhotoResource) | Metrics disabled | +| S-001-16 | I9a, I9b (ThumbRatingOverlay + Integration) | Thumbnail hover | +| S-001-17 | I9a, I9b (ThumbRatingOverlay + Integration) | Thumbnail click star | +| S-001-18 | I9c, I9d (PhotoRatingOverlay + Integration) | Full photo hover | +| S-001-19 | I9b (PhotoThumb integration) | Store setting respect | +| S-001-20 | I9c, I9d (PhotoRatingOverlay + Integration) | Auto-hide behavior | +| UI-001-01 | I8, I9 (Widget + Details) | No user rating state | +| UI-001-02 | I8, I9 (Widget + Details) | User has rated state | +| UI-001-03 | I8 (PhotoRatingWidget) | Hover preview | +| UI-001-04 | I8 (PhotoRatingWidget) | Loading state | +| UI-001-05 | I8 (PhotoRatingWidget) | Success state | +| UI-001-06 | I8, I10 (Widget + Error handling) | Error state | +| UI-001-07 | I8, I9 (Widget + Details) | Disabled (not logged in) | +| UI-001-08 | I8, I9 (Widget + Details) | No ratings display | +| UI-001-09 | I9a, I9b (ThumbRatingOverlay) | Thumbnail overlay hover | +| UI-001-10 | I9a, I9b (ThumbRatingOverlay) | Thumbnail overlay click | +| UI-001-11 | I9c, I9d (PhotoRatingOverlay) | Photo overlay hover | +| UI-001-12 | I9c, I9d (PhotoRatingOverlay) | Photo overlay auto-hide | +| UI-001-13 | I9a, I9c (Both overlays) | Mobile disabled | +| FR-001-11 | I12a (Config settings) | Show avg in details setting | +| FR-001-12 | I12a (Config settings) | Show avg in photo view setting | +| FR-001-13 | I12a (Config settings) | Photo view mode setting | +| FR-001-14 | I12a (Config settings) | Show avg in album view setting | +| FR-001-15 | I12a (Config settings) | Album view mode setting | +| FR-001-16 | I12a (Config settings) | Ratings enabled master switch | + +## Analysis Gate + +**Status:** Not yet executed + +**Checklist (to be completed before implementation):** +- [ ] Spec reviewed and approved +- [ ] Plan reviewed and approved +- [ ] All high/medium-impact questions resolved +- [ ] Dependencies identified and available +- [ ] Test strategy defined +- [ ] Increment breakdown reasonable (all ≤90 min) +- [ ] No architectural conflicts with existing code + +**Findings:** _To be populated during analysis gate review_ + +## Exit Criteria + +- [x] All migrations run successfully (up and down) +- [x] PhotoRating model created with relationships +- [x] Statistics model enhanced with rating columns +- [x] POST /Photo::rate endpoint implemented +- [x] PhotoResource includes rating data +- [x] Frontend service method added +- [x] PhotoRatingWidget component created (details drawer) +- [x] ThumbRatingOverlay component created (thumbnail hover) +- [x] PhotoRatingOverlay component created (full photo hover) +- [x] PhotoRatingWidget integrated into PhotoDetails drawer +- [x] ThumbRatingOverlay integrated into PhotoThumb component +- [x] PhotoRatingOverlay integrated into PhotoPanel component +- [x] 6 config settings implemented (FR-001-11 through FR-001-16) +- [x] All unit tests pass (models, relationships) +- [x] All feature tests pass (API endpoints, validation, errors, concurrency) +- [x] All frontend tests pass (component, integration) +- [x] PHPStan level 6 passes with no errors +- [x] PHP CS Fixer passes (code style) +- [x] Prettier passes (frontend formatting) +- [x] Manual smoke test completed (all scenarios verified): + - [x] Rate in details drawer + - [x] Rate on thumbnail hover + - [x] Rate on full-size photo hover + - [x] Overlay auto-hide behavior + - [x] Mobile responsiveness (overlays hidden) + - [x] Store setting respect + - [x] Loading/success/error states + - [x] Not logged in state +- [x] Documentation updated (knowledge map, roadmap, API docs) +- [x] No security vulnerabilities (SQL injection, XSS, authorization bypass) +- [x] Performance verified (<500ms p95 for rating operations) +- [x] License headers in all new PHP files +- [x] Code follows conventions (snake_case, strict comparison, no empty(), etc.) + +## Follow-ups / Backlog + +**Deferred Enhancements (Post-Feature):** +1. **Album aggregate ratings** (Q001-21 → Option A): Display average album rating based on photo ratings +2. **Rating notifications** (Q001-23 → Option A): Notify photo owner when photo receives ratings +3. **Accessibility enhancements** (Q001-16 → Option C): Ensure star rating components meet WCAG 2.1 AA standards (keyboard navigation, screen reader support, focus indicators) +4. **Overlay performance optimization:** Consider debouncing hover events and lazy-loading rating data for large albums + +**Explicitly Out of Scope (Resolved Questions):** +- **Rating export/import** (Q001-22 → Option C): Not implementing export functionality +- **Data integrity audit command** (Q001-24 → Option B): Not needed - trust transactions +- **Telemetry/analytics** (Q001-19): No telemetry events or metrics collection + +**Technical Debt:** +- None identified yet (to be updated during implementation) + +**Monitoring:** +- Monitor query performance on photo_ratings table as rating volume grows +- Monitor statistics calculation accuracy (spot check aggregate vs. computed) +- Monitor transaction deadlocks or lock wait timeouts under high concurrency + +--- + +*Last updated: 2025-12-27* diff --git a/docs/specs/4-architecture/features/001-photo-star-rating/spec.md b/docs/specs/4-architecture/features/001-photo-star-rating/spec.md new file mode 100644 index 00000000000..aa05cb82cc8 --- /dev/null +++ b/docs/specs/4-architecture/features/001-photo-star-rating/spec.md @@ -0,0 +1,685 @@ +# Feature 001 – Photo Star Rating + +| Field | Value | +|-------|-------| +| Status | Draft | +| Last updated | 2025-12-27 | +| Owners | User | +| Linked plan | `docs/specs/4-architecture/features/001-photo-star-rating/plan.md` | +| Linked tasks | `docs/specs/4-architecture/features/001-photo-star-rating/tasks.md` | +| Roadmap entry | #001 | + +> Guardrail: This specification is the single normative source of truth for the feature. Track high- and medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), encode resolved answers directly in the Requirements/NFR/Behaviour/UI/Telemetry sections below (no per-feature `## Clarifications` sections), and use ADRs under `docs/specs/5-decisions/` for architecturally significant clarifications (referencing their IDs from the relevant spec sections). + +## Overview + +This feature adds star rating functionality to individual photos, allowing logged-in users to rate photos on a 1-5 scale. The system stores both aggregate statistics (sum and count) on the Photo model for performance and individual user ratings for tracking and preventing duplicate votes. The UI displays an interactive rating widget at the bottom of the photo view with immediate visual feedback. + +**Affected modules:** Core (Photo model, Statistics model, new PhotoRating model), Application (PhotoController, Request/Resource classes), REST API (new rating endpoint), UI (PhotoDetails component). + +## Goals + +- Allow logged-in users to rate photos from 1 to 5 stars +- Store aggregate rating data (sum and count) on the Photo Statistics model for efficient display +- Track individual user ratings to prevent duplicate voting and allow rating updates/removal +- Display current average rating and rating count in the photo details view +- Provide intuitive UI for selecting/changing/removing ratings at the bottom of photo view +- Maintain consistency with existing Lychee patterns (favorites, statistics, metadata updates) + +## Non-Goals + +- Rating photos anonymously (must be logged in) +- Rating albums or other entities (only individual photos) +- Public display of who rated what (individual ratings are private) +- Rating history or audit trail beyond current user's rating +- Rating notifications or social features +- Advanced rating analytics or trends + +## Functional Requirements + +| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | +|----|-------------|--------------|-----------------|--------------|--------------------|--------| +| FR-001-01 | Logged-in user can rate a photo 1-5 stars via UI or API | POST `/Photo::rate` with `photo_id`, `rating` (1-5) returns updated PhotoResource with new average and count. User's rating is stored/updated in `photo_ratings` table. Statistics updated atomically. | Rating must be integer 1-5. User must be authenticated. Photo must exist and **user must have read access** (Q001-05 → Option B: rating is lightweight engagement like favoriting, not privileged edit). | 401 if not authenticated, 403 if no access, 404 if photo not found, 422 if rating invalid. | No telemetry (Q001-19). | User requirement, Q001-05, Q001-19 | +| FR-001-02 | Logged-in user can remove their rating by setting rating to 0 | POST `/Photo::rate` with `rating: 0` deletes user's rating record, decrements count, subtracts rating from sum, recalculates average. Returns updated PhotoResource with **200 OK**. | Rating value 0 is special signal for removal. **Idempotent: removing non-existent rating returns 200 OK (no-op)** (Q001-06 → simpler client logic, standard REST pattern). | Returns 200 OK even if rating doesn't exist (idempotent removal). 401 if not authenticated, 403 if no photo access. | No telemetry (Q001-19). | User requirement, Q001-06, Q001-19 | +| FR-001-03 | User can update their existing rating | POST `/Photo::rate` with new rating value replaces existing rating. Updates sum (subtract old, add new), keeps same count. Returns updated PhotoResource. | Same validation as FR-001-01. Detect existing rating by user_id + photo_id uniqueness. | Same as FR-001-01. | No telemetry (Q001-19). | User requirement, Q001-19 | +| FR-001-04 | Photo details view displays current average rating and vote count | PhotoResource includes `rating_avg` (decimal 0-5, nullable) and `rating_count` (integer >= 0) when metrics are enabled. UI displays stars visualization and count text. | Only show when `metrics_enabled` config is true and user has `CAN_READ_METRICS` permission, consistent with other statistics. | If no ratings exist, show 0 count and no average (nullable). | No event (read operation). | User requirement | +| FR-001-05 | Photo details view displays user's current rating (if any) | PhotoResource includes `user_rating` (integer 1-5, nullable) representing current user's rating. UI pre-selects corresponding star. | Only when user is authenticated. Nullable when user hasn't rated. | If not authenticated, field is null. | No event (read operation). | User requirement | +| FR-001-06 | Rating UI appears in photo details drawer | Interactive star rating component positioned in PhotoDetails drawer below statistics section. Shows 5 clickable star icons (1-5) and a reset option (0). | Follows existing PhotoDetails drawer layout patterns. Only visible to logged-in users. | Read-only for anonymous users (if viewing is allowed). | No event (UI state). | User requirement | +| FR-001-09 | Rating overlay appears on photo thumbnail hover | When mouse hovers over photo thumbnail in album grid, star rating overlay appears at bottom of thumbnail. Shows current average rating and interactive rating selector with **inline [0] button** for removal. Clicking star rates photo without opening details. | Uses existing thumbnail overlay pattern (group-hover, opacity transition). Only visible on desktop (md: breakpoint). Follows `display_thumb_photo_overlay` store pattern. Button 0 shown as "×" or "Remove" for clarity. | Hidden on mobile (details drawer only), respects overlay settings. | No event (UI state). | User requirement, Q001-03 (Option A), Q001-04 (Option A) | +| FR-001-10 | Rating overlay appears on full-size photo hover (lower area) | When viewing full-size photo, hovering over lower portion of image reveals rating overlay. Shows average rating and interactive selector. User can rate without opening details drawer. | Positioned **bottom-center** (horizontally centered, above metadata overlay). Uses gradient background for visibility. **Auto-hides after 3 seconds** of inactivity or when mouse leaves area. | Hidden if user preference disables overlays. Desktop-only (hidden on mobile below md: breakpoint). | No event (UI state). | User requirement, Q001-01 (Option A), Q001-02 (Option A), Q001-04 (Option A) | +| FR-001-07 | Statistics table stores aggregate rating data per photo | `photo_statistics` table gains `rating_sum` (unsigned big integer, default 0) and `rating_count` (unsigned integer, default 0). Average calculated as `sum / count` when count > 0. | Migration adds columns with default values. Existing photos have 0/0 (no ratings). Updates are atomic within rating transaction. | If statistics record doesn't exist, create it during first rating. | No event (schema change). | Performance requirement | +| FR-001-08 | PhotoRating table tracks individual user ratings | New `photo_ratings` table with columns: `id`, `photo_id` (char 24, FK to photos), `user_id` (int, FK to users), `rating` (tinyint 1-5), `created_at`, `updated_at`. Unique constraint on `(photo_id, user_id)`. | On rate action, upsert rating record. On remove (rating 0), delete record. | Foreign key constraints ensure data integrity. | No event (schema change). | Tracking requirement | +| FR-001-11 | Setting: Show average rating in photo details | Boolean config setting `rating_show_avg_in_details` (default: true). When enabled, PhotoDetails drawer displays aggregate rating (average + count). When disabled, aggregate is hidden (user's own rating may still be shown). | Stored in `configs` database table. Read on app initialization. | If disabled, average/count not rendered in PhotoDetails UI. | No event (config read). | User request | +| FR-001-12 | Setting: Show average rating in photo view | Boolean config setting `rating_show_avg_in_photo_view` (default: true). When enabled, full-size photo overlay (PhotoRatingOverlay) displays aggregate rating. When disabled, only user's rating selector shown. | Stored in `configs` database table. Read on app initialization. | If disabled, average/count not rendered in PhotoRatingOverlay. | No event (config read). | User request | +| FR-001-13 | Setting: Show rating UI in photo view (visibility mode) | Enum config setting `rating_photo_view_mode` with values: `always` (always visible, no auto-hide), `hover` (default: appear on hover, auto-hide after 3s), `hidden` (never show overlay). Controls PhotoRatingOverlay visibility behavior. | Stored in `configs` database table as string value. Default: `hover`. | Mode controls overlay rendering and auto-hide logic. `hidden` = no overlay rendered at all. | No event (config read). | User request | +| FR-001-14 | Setting: Show average rating in album view | Boolean config setting `rating_show_avg_in_album_view` (default: true). When enabled, thumbnail overlay (ThumbRatingOverlay) displays aggregate rating. When disabled, only user's rating selector shown. | Stored in `configs` database table. Read on app initialization. | If disabled, average/count not rendered in ThumbRatingOverlay. | No event (config read). | User request | +| FR-001-15 | Setting: Show rating UI in album view (visibility mode) | Enum config setting `rating_album_view_mode` with values: `always` (always visible on thumbnails), `hover` (default: appear on thumbnail hover), `hidden` (never show thumbnail overlay). Controls ThumbRatingOverlay visibility behavior. | Stored in `configs` database table as string value. Default: `hover`. Interacts with existing `display_thumb_photo_overlay` setting. | Mode controls overlay rendering. `hidden` = no rating overlay on thumbnails. | No event (config read). | User request | +| FR-001-16 | Setting: Enable rating functionality | Boolean config setting `ratings_enabled` (default: true). When disabled, all rating UI hidden and `/Photo::rate` endpoint disabled. Independent of `metrics_enabled` setting. Allows granular control over rating vs metrics display. | Stored in `configs` database table. Default: `true` (enabled). | When false, hide all rating widgets/overlays and return 403 from rating endpoint. | No event (config read). | Q001-11 (Option C) | + +## Non-Functional Requirements + +| ID | Requirement | Driver | Measurement | Dependencies | Source | +|----|-------------|--------|-------------|--------------|--------| +| NFR-001-01 | Rating updates must be atomic | Prevent race conditions when multiple users rate simultaneously or user rapidly changes rating | Use database transactions wrapping: (1) upsert/delete photo_ratings record, (2) update statistics sum/count. Test with concurrent requests. | Laravel DB transactions, unique constraints | Data integrity standard | +| NFR-001-02 | Average rating precision is 2 decimal places | Display consistency and rounding clarity | Store as `DECIMAL(3,2)` (range 0.00-5.00). Display formatted to 2 decimals. **Use PrimeVue half-star icons** (pi-star, pi-star-fill, pi-star-half, pi-star-half-fill) for visual representation (Q001-13 → Option B). | Database schema, PhotoResource serialization, PrimeVue icons | UX consistency, Q001-13 | +| NFR-001-03 | Rating endpoint response time < 500ms (p95) | Maintain UI responsiveness | Single photo rating should complete in sub-second, even under load. Use database indexes on photo_id and user_id. | Indexed foreign keys, efficient query patterns | Performance standard | +| NFR-001-04 | Must follow existing authorization patterns | Consistency with photo access controls | Use `authorize()` logic based on photo read access (Q001-05 → Option B: rating is lightweight engagement like favoriting, not privileged edit). **User must have read access to photo** (same as viewing). Reuse middleware `login_required:album`. | Photo authorization traits, existing middleware | Security consistency, Q001-05 | +| NFR-001-05 | Code follows Lychee PHP conventions | Maintainability and code quality | License headers, snake_case variables, strict comparison (===), PSR-4, no `empty()`, `in_array(..., true)`. Extends appropriate base classes. | php-cs-fixer, phpstan level 6 | [docs/specs/3-reference/coding-conventions.md](../../../3-reference/coding-conventions.md) | +| NFR-001-06 | Frontend follows Vue3/TypeScript conventions | Maintainability and code quality | Template-first component structure, Composition API, regular function declarations (no arrow functions), `.then()` instead of async/await, axios calls in services directory. | Prettier, frontend tests | [docs/specs/3-reference/coding-conventions.md](../../../3-reference/coding-conventions.md) | +| NFR-001-07 | Test coverage for all rating paths | Ensure correctness and prevent regression | Unit tests for rating calculation logic. Feature tests for API endpoints covering: new rating, update rating, remove rating, concurrent updates, unauthorized access. Frontend tests for UI component states. | AbstractTestCase, BaseApiWithDataTest, in-memory SQLite | Testing standard | + +## UI / Interaction Mock-ups + +### 1. Photo Thumbnail Rating Overlay (Album Grid View) + +``` +┌──────────────────────────────────────┐ +│ Album: Summer Vacation 2025 │ +├──────────────────────────────────────┤ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ │ [Photo] │ │ [Photo] │ │ [Photo] │ ← Grid of thumbnails +│ │ │ │ HOVER │ │ │ +│ │ │ │ │ │ │ +│ │ ☆ │ │ ★ ♡ 🛒 │ │ ☆ │ ← Top badges/actions +│ │ │ │─────────│ │ │ +│ │ │ │ Sunset │ │ │ ← Metadata overlay +│ │ │ │ 2025... │ │ │ (existing pattern) +│ │ │ ├─────────┤ │ │ +│ │ │ │ ★★★★☆ │ │ │ ← Rating overlay +│ │ │ │ 4.2(15) │ │ │ (NEW - on hover) +│ │ │ │ [1][2] │ │ │ Interactive stars +│ │ │ │ [3][4] │ │ │ appear at bottom +│ │ │ │ [5][0] │ │ │ of thumbnail +│ └─────────┘ └─────────┘ └─────────┘ +│ +└──────────────────────────────────────┘ +``` + +**States:** + +**A. Default state (no hover):** +``` +┌─────────┐ +│ [Photo] │ +│ │ +│ ☆ │ ← Only badges visible (starred, cover, etc.) +│ │ +│ │ +│ │ +└─────────┘ +``` + +**B. Hover state - rating overlay appears:** +``` +┌─────────┐ +│ [Photo] │ +│ ★ ♡ 🛒 │ ← Action buttons (existing: favorite, buy) +│─────────│ +│ Sunset │ ← Metadata overlay (existing, respects settings) +│ 2025... │ +├─────────┤ +│ ★★★★☆ │ ← NEW: Average rating display (4.2 avg) +│ 4.2(15) │ "4.2 stars, 15 votes" +│ Rate: │ ← NEW: Interactive rating selector +│ ★★★★☆ │ User's current rating: 4 stars +│ [1-4] │ Stars 1-4 filled, 5 empty (cumulative display) +└─────────┘ +``` + +**C. Interaction - hovering over star 3 (to change from 4 to 3):** +``` +├─────────┤ +│ ★★★★☆ │ ← Average unchanged (4.2) +│ 4.2(15) │ +│ Rate: │ +│ ★★★☆☆ │ ← Preview shows stars 1-3 filled (hover at 3) +│ [1-3] │ Click star 3 to rate 3 stars +└─────────┘ +``` + +**D. After clicking star 3 (rating changed to 3):** +``` +├─────────┤ +│ ★★★☆☆ │ ← Average updated (now 3.8) +│ 3.8(15) │ Statistics recalculated +│ Rate: │ +│ ★★★☆☆ │ ← Your rating: 3 stars (1-3 filled) +│ [saved] │ Toast: "Rating updated to 3 stars" +└─────────┘ +``` + +**Implementation notes:** +- **Rating removal:** Inline [0] button shown as "×" or "Remove" (Q001-03 → Option A) +- **Mobile behavior:** Hidden on mobile/tablet below md: breakpoint (Q001-04 → Option A) +- Overlay appears on `group-hover` (desktop only, `md:` breakpoint) +- Uses gradient background: `bg-linear-to-t from-[#00000099]` +- Positioned at bottom of thumbnail (absolute positioning) +- Respects `display_thumb_photo_overlay` store setting +- Star size: compact (smaller than details view for space) +- Clicking star immediately rates photo (no confirmation) +- Toast notification confirms rating saved +- **IMPORTANT: Rating visualization is cumulative:** + - Rating 1: ★☆☆☆☆ (1 filled) + - Rating 2: ★★☆☆☆ (1-2 filled) + - Rating 3: ★★★☆☆ (1-3 filled) + - Rating 4: ★★★★☆ (1-4 filled) + - Rating 5: ★★★★★ (1-5 filled) + - No rating: ☆☆☆☆☆ (all empty) + +--- + +### 2. Full-Size Photo Rating Overlay (Photo View) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ [Full-size photo] │ +│ │ +│ │ +│ │ +│ │ +│ ┌──────────────────────────────────────────┐ ← Lower area │ +│ │ │ hover zone │ +│ │ [Mouse hovering lower area] │ │ +│ │ │ │ +│ │ ┌────────────────────────────────────┐ │ ← Overlay │ +│ │ │ ★★★★☆ 4.2 (15 votes) │ │ appears │ +│ │ │ Your rating: [ 0 ][ 1 ][ 2 ][ 3 ] │ │ │ +│ │ │ [ 4 ][ 5 ] │ │ │ +│ │ │ × ☆ ☆ ☆ │ │ │ +│ │ │ ★ ☆ │ │ │ +│ │ └────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + ^ ^ + Overlay (title/EXIF) Dock buttons + (existing, bottom-left) (existing, bottom-right) +``` + +**Chosen positioning (Q001-01 → Option A):** + +``` +│ [Photo] │ +│ ┌────────────────────────────────────────────┐ │ +│ │ ★★★★☆ 4.2 Rate: ★★★★☆ [0][1][2][3][4][5]│ │ ← Bottom-center +│ └────────────────────────────────────────────┘ │ +│ Title: Sunset [Dock btns] │ +``` + +**Rationale:** Centered position is more discoverable and doesn't compete with Dock buttons. Symmetrical with metadata overlay below. + +--- + +**States:** + +**A. No hover - overlay hidden:** +``` +│ [Photo] │ +│ │ +│ Title: Sunset [Dock] │ +``` + +**B. Hover lower area - overlay appears (user has rated 4 stars):** +``` +│ [Photo] │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ★★★★☆ 4.2 (15) Your rating: ★★★★☆ │ │ +│ │ [0][1][2][3][4][5] (click to change) │ │ +│ └──────────────────────────────────────────┘ │ +│ Title: Sunset [Dock] │ +``` + +**C. Hover over star 3 while rated 4 (preview change):** +``` +│ [Photo] │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ★★★★☆ 4.2 (15) Preview: ★★★☆☆ (3) │ │ +│ │ [0][1][2][3][4][5] (click to rate 3) │ │ +│ └──────────────────────────────────────────┘ │ +│ Title: Sunset [Dock] │ +``` + +**Implementation notes:** +- **Positioning:** Bottom-center, horizontally centered (Q001-01 → Option A) +- **Auto-hide timer:** 3 seconds of inactivity (Q001-02 → Option A) +- **Rating removal:** Inline [0] button shown as "×" before stars (Q001-03 → Option A) +- **Mobile behavior:** Hidden on mobile/tablet (Q001-04 → Option A), rating only via details drawer +- Triggered by mouse entering lower 20-30% of photo area (desktop only, md: breakpoint) +- Semi-transparent gradient background for readability +- Persists while mouse is over the rating overlay itself (cancels auto-hide) +- Compact horizontal layout to minimize obstruction +- Respects `image_overlay_type` and overlay preference settings +- z-index layers properly with existing Overlay and Dock +- **Cumulative star display:** Rating N shows stars 1 through N filled + +--- + +### 3. Photo Details Drawer - Rating Widget + +``` +┌────────────────────────────────────────────────────────────┐ +│ Photo Details [×] │ +├────────────────────────────────────────────────────────────┤ +│ │ +│ [Photo metadata: title, description, EXIF, etc.] │ +│ │ +│ Statistics │ +│ ├─ Views: 142 │ +│ ├─ Downloads: 23 │ +│ ├─ Favorites: 8 │ +│ └─ Shares: 5 │ +│ │ +│ Rating │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Average: ★★★★☆ 4.2 (15 votes) │ │ +│ │ │ │ +│ │ Your rating: │ │ +│ │ [ 0 ] [ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ] │ │ +│ │ × ☆ ☆ ☆ ★ ☆ │ │ +│ │ │ │ │ │ │ │ │ +│ │ └─────┴─────┴─────┴─────┘ │ │ +│ │ Clickable star buttons (current: 4) │ │ +│ │ │ │ +│ │ ⇄ Click 1-5 to rate, 0 to remove your rating │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────┘ +``` + +**States:** + +1. **Not rated by user, no ratings exist:** + ``` + Average: No ratings yet + Your rating: [ 0 ] [ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ] + × ☆ ☆ ☆ ☆ ☆ + ``` + +2. **Not rated by user, others have rated:** + ``` + Average: ★★★☆☆ 3.4 (12 votes) + Your rating: [ 0 ] [ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ] + × ☆ ☆ ☆ ☆ ☆ + ``` + +3. **User has rated (example: 5 stars):** + ``` + Average: ★★★★☆ 4.2 (15 votes) + Your rating: [ 0 ] [ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ] + × ☆ ☆ ☆ ☆ ★ + ^selected + ``` + +4. **Hover state (hovering over 3):** + ``` + Your rating: [ 0 ] [ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ] + × ★ ★ ★ ☆ ☆ + └─────┴─────┘ + Hover preview + ``` + +**Interaction flow:** +- Click any number 1-5: Submit rating, update statistics, show success toast +- Click 0: Remove rating (if exists), update statistics, show success toast +- Visual feedback: Filled stars (★) for selected/hovered, empty (☆) for unselected +- Disabled state: Gray out if user not logged in or lacks permission + +## Branch & Scenario Matrix + +| Scenario ID | Description / Expected outcome | +|-------------|--------------------------------| +| S-001-01 | User rates unrated photo for first time → rating stored, statistics updated (count=1, avg=rating) | +| S-001-02 | User updates existing rating → old rating replaced, statistics recalculated (sum adjusted) | +| S-001-03 | User removes rating (sets to 0) → rating deleted, statistics updated (count-1, sum-old_rating) | +| S-001-04 | Multiple users rate same photo → each rating tracked separately, statistics aggregate correctly | +| S-001-05 | Concurrent rating updates by same user → last write wins, no duplicate records (unique constraint enforced) | +| S-001-06 | Concurrent ratings by different users → both succeed, statistics correctly reflect both (atomic updates) | +| S-001-07 | Unauthenticated user attempts to rate → 401 Unauthorized | +| S-001-08 | User without photo access attempts to rate → 403 Forbidden | +| S-001-09 | Invalid rating value (0.5, 6, -1, "abc") → 422 Unprocessable Entity with validation error | +| S-001-10 | Photo doesn't exist → 404 Not Found | +| S-001-11 | User views photo they haven't rated → UI shows average rating, user rating is null | +| S-001-12 | User views photo they have rated → UI shows average rating + pre-selects user's rating | +| S-001-13 | Photo with no ratings → displays "No ratings yet", count=0, avg=null | +| S-001-14 | User removes non-existent rating (rating 0 when never rated) → no-op, returns success (idempotent) | +| S-001-15 | Metrics disabled in config → rating data not shown in PhotoResource (but can still rate) | +| S-001-16 | User hovers over photo thumbnail → rating overlay appears at bottom with average and interactive stars | +| S-001-17 | User clicks star on thumbnail overlay → photo is rated, overlay updates, toast confirms | +| S-001-18 | User hovers over full-size photo lower area → rating overlay appears with current rating | +| S-001-19 | Thumbnail overlay respects `display_thumb_photo_overlay` setting (hover/always/never) | +| S-001-20 | Photo overlay auto-hides after inactivity or mouse leaves (configurable behavior) | +| S-001-21 | Setting `rating_show_avg_in_details` controls average display in PhotoDetails drawer | +| S-001-22 | Setting `rating_show_avg_in_photo_view` controls average display in PhotoRatingOverlay | +| S-001-23 | Setting `rating_photo_view_mode` controls overlay visibility (always/hover/hidden) | +| S-001-24 | Setting `rating_show_avg_in_album_view` controls average display in ThumbRatingOverlay | +| S-001-25 | Setting `rating_album_view_mode` controls thumbnail overlay visibility (always/hover/hidden) | + +## Test Strategy + +- **Core (Unit tests):** + - PhotoRating model relationships (belongsTo Photo, belongsTo User) + - Photo hasMany PhotoRatings relationship + - Statistics model rating_avg calculation helper (if added) + - Rating validation logic (1-5 range, integer only) + +- **Application (Feature tests):** + - `tests/Feature_v2/Photo/PhotoRatingTest.php`: + - POST `/Photo::rate` with valid rating (1-5) → 200, statistics updated + - POST `/Photo::rate` to update existing rating → 200, statistics recalculated + - POST `/Photo::rate` with rating=0 to remove → 200, statistics decremented + - POST `/Photo::rate` unauthenticated → 401 + - POST `/Photo::rate` without photo access → 403 + - POST `/Photo::rate` with invalid rating (6, 0.5, "abc") → 422 + - POST `/Photo::rate` with non-existent photo_id → 404 + - GET PhotoResource includes rating_avg, rating_count, user_rating + - Concurrent rating test (simulate race condition) → verify atomicity + - Rating removal idempotency test (remove twice) → no error + +- **REST (API contract):** + - OpenAPI schema for POST `/Photo::rate` endpoint + - Request schema: `{ photo_id: string, rating: 0|1|2|3|4|5 }` + - Response schema: PhotoResource with statistics embedded + - Error response schemas (401, 403, 404, 422) + +- **UI (Component tests):** + - `PhotoRating.vue` component: + - Renders 0-5 buttons correctly + - Pre-selects user's current rating if exists + - Displays average rating and count + - Handles click events (calls rating service) + - Shows loading state during API call + - Displays success/error toasts + - Disabled state when not logged in + - Hover preview shows filled stars up to hovered value + +- **Docs/Contracts:** + - Update knowledge map with PhotoRating model and relationships + - Update PhotoResource schema documentation + - Add rating endpoint to API documentation + +## Interface & Contract Catalogue + +### Domain Objects + +| ID | Description | Modules | +|----|-------------|---------| +| DO-001-01 | PhotoRating model: id, photo_id, user_id, rating (1-5), timestamps. Relationships: belongsTo Photo, belongsTo User. Unique constraint (photo_id, user_id). | core (Models) | +| DO-001-02 | Photo model enhancement: hasMany PhotoRatings relationship. | core (Models) | +| DO-001-03 | Statistics model enhancement: rating_sum (unsigned bigint), rating_count (unsigned int). Calculated field: rating_avg (decimal 3,2). | core (Models) | + +### API Routes / Services + +| ID | Transport | Description | Notes | +|----|-----------|-------------|-------| +| API-001-01 | POST /Photo::rate | Set or update user's rating for a photo. Body: `{ photo_id: string, rating: 0-5 }`. Response: PhotoResource with updated statistics. | Middleware: login_required:album | +| API-001-02 | GET /Photo (existing) | Enhanced to include rating data in PhotoResource: rating_avg, rating_count (when metrics enabled), user_rating (when authenticated). | No API change, response enhancement | + +### CLI Commands / Flags + +Not applicable (no CLI component for this feature). + +### Telemetry Events + +Not applicable (no telemetry/analytics for this feature per Q001-19). + +### Fixtures & Sample Data + +| ID | Path | Purpose | +|----|------|---------| +| FX-001-01 | `tests/Feature_v2/Photo/fixtures/photos_with_ratings.json` | Sample photos with varying rating counts and averages for testing display logic. | +| FX-001-02 | Database seeder (in-memory) | Seed photo_ratings records for testing concurrent updates, edge cases (single rating, many ratings, etc.). | + +### UI States + +| ID | State | Trigger / Expected outcome | +|----|-------|---------------------------| +| UI-001-01 | Rating widget - no user rating | User hasn't rated this photo. Display average + "Your rating" with unselected stars (0-5 buttons). | +| UI-001-02 | Rating widget - user has rated | User has rated this photo. Display average + pre-select user's rating button. | +| UI-001-03 | Rating widget - hover preview | User hovers over star button 1-5. Show filled stars **1 through N** for hover value N (cumulative visual preview). For example, hovering over star 3 shows stars 1, 2, 3 filled and 4, 5 empty. | +| UI-001-04 | Rating widget - loading | API call in progress. Disable buttons, show loading indicator. | +| UI-001-05 | Rating widget - success | Rating saved successfully. Show success toast, update display with new average/count. | +| UI-001-06 | Rating widget - error | API error (network, validation, auth). Show error toast with message. | +| UI-001-07 | Rating widget - disabled (not logged in) | User not authenticated. Gray out buttons, show tooltip "Log in to rate". | +| UI-001-08 | Rating display - no ratings | No one has rated this photo. Display "No ratings yet" instead of average. | +| UI-001-09 | Thumbnail rating overlay - hover | Mouse hovers over thumbnail. Overlay appears at bottom with gradient background, shows average + interactive stars. | +| UI-001-10 | Thumbnail rating overlay - click star | User clicks star on overlay. Loading indicator, then success toast, overlay updates with new average. | +| UI-001-11 | Photo rating overlay - lower area hover | Mouse in lower 20-30% of full-size photo. Overlay appears (center or bottom-right) with rating UI. | +| UI-001-12 | Photo rating overlay - auto-hide | After 3s inactivity or mouse leaves area, overlay fades out. Persists if mouse over overlay itself. | +| UI-001-13 | Mobile - overlays disabled | On mobile/tablet (below md: breakpoint), rating overlays hidden. Rating only via details drawer. | + +## Telemetry & Observability + +Not applicable (no telemetry/analytics for this feature per Q001-19). + +**Logging (standard application logs only):** +- INFO: Successful rating creation/update/removal +- WARNING: Validation failures (invalid rating value) +- ERROR: Database transaction failures, foreign key violations + +## Documentation Deliverables + +- **Roadmap update:** Add Feature 001 to Active Features table +- **Knowledge map update:** Add PhotoRating model, relationships to Photo and User, Statistics column additions +- **API documentation:** Document POST `/Photo::rate` endpoint, updated PhotoResource schema +- **Feature README (this spec):** Serve as implementation reference +- **ADR (if applicable):** Decision on statistics denormalization vs. computed properties (deferred unless needed) + +## Fixtures & Sample Data + +**Test fixtures needed:** +1. `tests/Feature_v2/Photo/fixtures/unrated_photo.json` - Photo with no ratings (count=0, avg=null) +2. `tests/Feature_v2/Photo/fixtures/rated_photos.json` - Photos with various rating scenarios: + - Single 5-star rating + - Multiple ratings with fractional average (e.g., 4.33) + - Many ratings (e.g., 100 ratings, avg 3.8) +3. Database seeder entries for photo_ratings table with known user_id/photo_id pairs + +## Spec DSL + +```yaml +domain_objects: + - id: DO-001-01 + name: PhotoRating + table: photo_ratings + fields: + - name: id + type: bigint + constraints: primary key + - name: photo_id + type: char(24) + constraints: foreign key (photos.id), indexed + - name: user_id + type: integer + constraints: foreign key (users.id), indexed + - name: rating + type: tinyint + constraints: "1-5" + - name: created_at + type: timestamp + - name: updated_at + type: timestamp + constraints: + - unique: [photo_id, user_id] + - id: DO-001-02 + name: Photo + enhancements: + - hasMany: PhotoRatings + - id: DO-001-03 + name: Statistics + table: photo_statistics + new_fields: + - name: rating_sum + type: unsigned_bigint + default: 0 + - name: rating_count + type: unsigned_int + default: 0 + computed_fields: + - name: rating_avg + type: decimal(3,2) + formula: "rating_sum / rating_count (when count > 0, else null)" + +routes: + - id: API-001-01 + method: POST + path: /Photo::rate + middleware: login_required:album + request: + photo_id: string (required, exists in photos) + rating: integer (required, 0-5) + response: + success: PhotoResource (200) + errors: + - 401: Unauthenticated + - 403: Forbidden (no photo access) + - 404: Photo not found + - 422: Validation failed (invalid rating) + - id: API-001-02 + method: GET + path: /Photo + enhancements: + - rating_avg: decimal (nullable, in statistics section) + - rating_count: integer (in statistics section) + - user_rating: integer (nullable, 1-5, top level) + +telemetry_events: [] # No telemetry (Q001-19) + +fixtures: + - id: FX-001-01 + path: tests/Feature_v2/Photo/fixtures/photos_with_ratings.json + purpose: Display testing + - id: FX-001-02 + type: database_seeder + purpose: Concurrent update testing + +ui_states: + - id: UI-001-01 + description: No user rating, display average + unselected stars + - id: UI-001-02 + description: User has rated, pre-select user rating + - id: UI-001-03 + description: Hover preview on star buttons + - id: UI-001-04 + description: Loading state during API call + - id: UI-001-05 + description: Success state with toast + - id: UI-001-06 + description: Error state with toast + - id: UI-001-07 + description: Disabled state (not logged in) + - id: UI-001-08 + description: No ratings yet display +``` + +## Appendix + +### Database Schema Reference + +**Migration: `create_photo_ratings_table`** +```sql +CREATE TABLE photo_ratings ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + photo_id CHAR(24) NOT NULL, + user_id INT UNSIGNED NOT NULL, + rating TINYINT UNSIGNED NOT NULL CHECK (rating BETWEEN 1 AND 5), + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + UNIQUE KEY unique_photo_user_rating (photo_id, user_id), + FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_photo_id (photo_id), + INDEX idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +**Migration: `add_rating_columns_to_statistics`** +```sql +ALTER TABLE photo_statistics +ADD COLUMN rating_sum BIGINT UNSIGNED NOT NULL DEFAULT 0, +ADD COLUMN rating_count INT UNSIGNED NOT NULL DEFAULT 0; +``` + +### API Request/Response Examples + +**Request: Rate a photo (new rating)** +```http +POST /Photo::rate HTTP/1.1 +Content-Type: application/json +Authorization: Bearer + +{ + "photo_id": "abc123def456ghi789jkl012", + "rating": 4 +} +``` + +**Response: Success (200)** +```json +{ + "id": "abc123def456ghi789jkl012", + "title": "Sunset Over Mountains", + "description": "Beautiful sunset...", + "is_starred": false, + "owner_id": 42, + "statistics": { + "visit_count": 142, + "download_count": 23, + "favourite_count": 8, + "shared_count": 5, + "rating_avg": 4.20, + "rating_count": 15 + }, + "user_rating": 4, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-12-27T14:22:00Z" +} +``` + +**Request: Remove rating** +```http +POST /Photo::rate HTTP/1.1 +Content-Type: application/json +Authorization: Bearer + +{ + "photo_id": "abc123def456ghi789jkl012", + "rating": 0 +} +``` + +**Response: Success (200, rating removed)** +```json +{ + "id": "abc123def456ghi789jkl012", + "statistics": { + "rating_avg": 4.14, + "rating_count": 14 + }, + "user_rating": null +} +``` + +**Response: Validation error (422)** +```json +{ + "message": "The given data was invalid.", + "errors": { + "rating": [ + "The rating must be between 0 and 5." + ] + } +} +``` + +### Implementation Notes + +- **Atomic updates:** Use `DB::transaction()` wrapper around PhotoRating upsert and Statistics update +- **Efficiency:** Consider adding database trigger or observer pattern to auto-update statistics (evaluate trade-offs in plan phase) +- **Future enhancement:** If rating volume becomes high, consider moving to event-driven update (queue job) instead of synchronous +- **Consistency check:** Provide artisan command to recalculate all statistics from photo_ratings table (for data integrity audits) + +--- + +*Last updated: 2025-12-27* diff --git a/docs/specs/4-architecture/features/001-photo-star-rating/tasks.md b/docs/specs/4-architecture/features/001-photo-star-rating/tasks.md new file mode 100644 index 00000000000..caabc756066 --- /dev/null +++ b/docs/specs/4-architecture/features/001-photo-star-rating/tasks.md @@ -0,0 +1,760 @@ +# Feature 001 – Photo Star Rating – Implementation Tasks + +_Linked plan:_ [plan.md](plan.md) +_Status:_ Feature Complete ✅ | Performance Optimization (I14) Remaining ⚠️ +_Last updated:_ 2025-12-27 + +## Task Overview + +This document tracks the 17 increments from the implementation plan as individual tasks. Each task is estimated at ≤90 minutes and includes specific deliverables, test requirements, and exit criteria. + +**Total estimated effort:** ~15 hours (900 minutes) + +## Task Status Legend + +- ⏳ **Not Started** - Task not yet begun +- 🔄 **In Progress** - Currently being worked on +- ✅ **Complete** - All exit criteria met, tests passing +- ⚠️ **Blocked** - Waiting on dependency or clarification + +--- + +## Backend Tasks (Increments I1-I6, I10-I11) + +### I1 – Database Schema & Migrations ✅ +**Estimated:** 60 minutes +**Dependencies:** None +**Status:** Complete + +**Deliverables:** +- [x] Migration: `create_photo_ratings_table` + - [x] Columns: id, photo_id (char 24, FK), user_id (int, FK), rating (tinyint 1-5), timestamps + - [x] Unique constraint: (photo_id, user_id) + - [x] Foreign keys with CASCADE delete + - [x] Indexes on photo_id and user_id +- [x] Migration: `add_rating_columns_to_photo_statistics` + - [x] Add rating_sum (BIGINT UNSIGNED, default 0) + - [x] Add rating_count (INT UNSIGNED, default 0) +- [x] Test migrations run successfully (up and down) + +**Exit Criteria:** +- ✅ `php artisan migrate` succeeds +- ✅ `php artisan migrate:rollback --step=2` succeeds +- ✅ Tables created with correct schema +- ✅ Foreign keys and indexes present + +**Commands:** +```bash +php artisan make:migration create_photo_ratings_table +php artisan make:migration add_rating_columns_to_photo_statistics +php artisan migrate +php artisan migrate:rollback --step=2 +php artisan migrate +``` + +--- + +### I2 – PhotoRating Model & Relationships ✅ +**Estimated:** 60 minutes +**Dependencies:** I1 +**Status:** Complete + +**Deliverables:** +- [x] Unit test: `tests/Unit/Models/PhotoRatingTest.php` _(Covered by feature tests instead)_ + - [x] Test belongsTo Photo relationship _(Verified in integration tests)_ + - [x] Test belongsTo User relationship _(Verified in integration tests)_ + - [x] Test rating attribute casting (integer) _(Verified in integration tests)_ + - [x] Test validation (rating must be 1-5) _(Verified in SetPhotoRatingRequestTest)_ +- [x] Model: `app/Models/PhotoRating.php` + - [x] License header + - [x] Table name: photo_ratings + - [x] Fillable: photo_id, user_id, rating + - [x] Casts: rating => integer, timestamps disabled + - [x] Relationships: belongsTo Photo, belongsTo User +- [x] Update Photo model: add hasMany PhotoRatings relationship +- [ ] Update User model: add hasMany PhotoRatings relationship _(Not required for current functionality)_ + +**Exit Criteria:** +- ✅ All unit tests pass +- ✅ Relationships work correctly +- ✅ PHPStan level 6 passes +- ✅ php-cs-fixer passes + +**Commands:** +```bash +php artisan test tests/Unit/Models/PhotoRatingTest.php +make phpstan +vendor/bin/php-cs-fixer fix +``` + +--- + +### I3 – Statistics Model Enhancement ✅ +**Estimated:** 45 minutes +**Dependencies:** I1 +**Status:** Complete + +**Deliverables:** +- [x] Unit test: `tests/Unit/Models/StatisticsTest.php` _(Covered by feature tests instead)_ + - [x] Test rating_avg accessor (sum / count when count > 0, else null) _(Verified in PhotoResourceRatingTest)_ + - [x] Test rating_sum and rating_count attributes _(Verified in integration tests)_ +- [x] Update Statistics model: `app/Models/Statistics.php` + - [x] Add rating_sum and rating_count to fillable/casts + - [x] Add accessor: `getRatingAvgAttribute()` returns decimal(3,2) or null + - [x] Cast rating_sum as integer, rating_count as integer + +**Exit Criteria:** +- ✅ rating_avg calculation works correctly +- ✅ All tests green +- ✅ PHPStan passes + +**Commands:** +```bash +php artisan test tests/Unit/Models/StatisticsTest.php +make phpstan +``` + +--- + +### I4 – SetPhotoRatingRequest Validation ✅ +**Estimated:** 60 minutes +**Dependencies:** None (parallel) +**Status:** Complete + +**Deliverables:** +- [x] Feature test: `tests/Feature_v2/Photo/SetPhotoRatingRequestTest.php` _(12 tests passing)_ + - [x] Test rating validation: must be 0-5 + - [x] Test rating must be integer (not string, float) + - [x] Test photo_id required and exists + - [x] Test authentication required + - [x] Test authorization (user has photo access) +- [x] Request class: `app/Http/Requests/Photo/SetPhotoRatingRequest.php` + - [x] License header + - [x] Rules: photo_id (required, RandomIDRule), rating (required, integer, min:0, max:5) + - [x] Authorize: user must have read access to photo (CAN_SEE policy - Q001-05) +- [x] Added RATING_ATTRIBUTE constant to RequestAttribute.php + +**Exit Criteria:** +- ✅ Validation works correctly +- ✅ All test scenarios pass +- ✅ PHPStan passes + +**Commands:** +```bash +php artisan test tests/Feature_v2/Photo/SetPhotoRatingRequestTest.php +make phpstan +``` + +--- + +### I5 – PhotoController::rate Method (Core Logic) ✅ +**Estimated:** 90 minutes +**Dependencies:** I1, I2, I3, I4 +**Status:** Complete + +**Deliverables:** +- [x] Feature test: `tests/Feature_v2/Photo/PhotoRatingIntegrationTest.php` _(5 tests passing)_ + - [x] Test POST /Photo::setRating creates new rating (S-001-01) + - [x] Test POST /Photo::setRating updates existing rating (S-001-02) + - [x] Test POST /Photo::setRating with rating=0 removes rating (S-001-03) + - [x] Test statistics updated correctly (sum and count) + - [x] Test response includes updated PhotoResource + - [x] Test idempotent removal - returns 201 Created (Q001-06) + - [x] Test 409 Conflict on transaction failure (Q001-08) _(Handled in Rating action)_ +- [x] Implement `PhotoController::rate()` method + - [x] Accept SetPhotoRatingRequest with dependency injection + - [x] Created `app/Actions/Photo/Rating.php` action class + - [x] Use closure-based DB::transaction with 409 Conflict error handling + - [x] Use firstOrCreate for statistics record (Q001-07) + - [x] Handle rating > 0: upsert PhotoRating, update statistics + - [x] Handle rating == 0: delete PhotoRating, idempotent + - [x] Return PhotoResource +- [x] Add route: `routes/api_v2.php` (POST /Photo::setRating) + +**Exit Criteria:** +- ✅ All rating scenarios work +- ✅ Atomic updates verified +- ✅ Tests green +- ✅ PHPStan passes +- ✅ Code style passes + +**Commands:** +```bash +php artisan test tests/Feature_v2/Photo/PhotoRatingTest.php +make phpstan +vendor/bin/php-cs-fixer fix app/Http/Controllers/PhotoController.php +``` + +**Key Pattern (Q001-07):** +```php +$statistics = PhotoStatistics::firstOrCreate( + ['photo_id' => $photo_id], + ['rating_sum' => 0, 'rating_count' => 0] +); +``` + +--- + +### I6 – PhotoResource Enhancement ✅ +**Estimated:** 60 minutes +**Dependencies:** I3, I5 +**Status:** Complete + +**Deliverables:** +- [x] Feature test: `tests/Feature_v2/Photo/PhotoResourceRatingTest.php` _(5 tests passing)_ + - [x] Test PhotoResource includes rating_avg and rating_count when metrics enabled + - [x] Test PhotoResource includes current_user_rating when user authenticated + - [x] Test current_user_rating is null when user hasn't rated + - [x] Test current_user_rating reflects user's actual rating + - [x] Test current_user_rating updates after rating change + - [x] Test current_user_rating is null after removal +- [x] Update PhotoStatisticsResource: `app/Http/Resources/Models/PhotoStatisticsResource.php` + - [x] Add rating_avg and rating_count to statistics +- [x] Update PhotoResource: `app/Http/Resources/Models/PhotoResource.php` + - [x] Add current_user_rating at top level +- [ ] Update PhotoController methods to eager load ratings (Q001-09) _(Deferred for performance optimization)_ + +**Exit Criteria:** +- ✅ PhotoResource includes all rating fields correctly +- ✅ Tests pass +- ✅ PHPStan passes + +**Commands:** +```bash +php artisan test tests/Feature_v2/Resources/PhotoResourceTest.php +make phpstan +``` + +**Key Pattern (Q001-09):** +```php +// Eager load user's rating to prevent N+1 queries +$photos->load(['ratings' => fn($q) => $q->where('user_id', auth()->id())]); +``` + +--- + +### I10 – Error Handling & Edge Cases ✅ +**Estimated:** 60 minutes +**Dependencies:** I5, I9 +**Status:** Complete + +**Deliverables:** +- [x] Feature tests for error scenarios: + - [x] POST /Photo::rate without auth → 401 + - [x] POST /Photo::rate without photo access → 403 + - [x] POST /Photo::rate with invalid rating (6, -1, "abc") → 422 + - [x] POST /Photo::rate with non-existent photo_id → 404 +- [ ] Verify frontend error handling (deferred to frontend implementation): + - [ ] Network error → show error toast + - [ ] 401/403/404/422 → show appropriate error message + - [ ] Loading state clears on error +- [x] Test statistics edge cases (covered in SetPhotoRatingRequestTest) + +**Exit Criteria:** +- ✅ All backend error scenarios handled gracefully +- ✅ Tests pass (12/12 tests passing in SetPhotoRatingRequestTest) + +**Commands:** +```bash +php artisan test tests/Feature_v2/Photo/PhotoRatingTest.php +npm run check +``` + +--- + +### I11 – Concurrency & Data Integrity Tests ✅ +**Estimated:** 60 minutes +**Dependencies:** I5 +**Status:** Complete + +**Deliverables:** +- [x] Concurrency test: `tests/Feature_v2/Photo/PhotoRatingConcurrencyTest.php` + - [x] Same user updates rating rapidly (last write wins) + - [x] Multiple users rate same photo concurrently +- [x] Verify unique constraint prevents duplicate records +- [x] Verify statistics sum and count remain consistent + +**Exit Criteria:** +- ✅ No race conditions +- ✅ Unique constraint enforced (ModelDBException thrown on duplicate) +- ✅ Statistics always consistent +- ✅ Tests pass (4/4 tests passing) + +**Commands:** +```bash +php artisan test tests/Feature_v2/Photo/PhotoRatingConcurrencyTest.php --repeat=10 +make phpstan +``` + +--- + +## Frontend Tasks (Increments I7-I9d, I12a) + +### I7 – Frontend Service Layer ✅ +**Estimated:** 45 minutes +**Dependencies:** I5 +**Status:** Complete + +**Deliverables:** +- [x] Update `resources/js/services/photo-service.ts` + - [x] Add method: `setRating(photo_id: string, rating: 0|1|2|3|4|5): Promise>` +- [x] Update TypeScript PhotoResource interface (auto-generated via `php artisan typescript:transform`) + - [x] Add rating_avg?: number | null + - [x] Add rating_count: number + - [x] Add current_user_rating?: number | null (0-5) +- [x] Document typescript:transform command in coding-conventions.md + +**Exit Criteria:** +- ✅ Service method compiles +- ✅ Types are correct (auto-generated from PHP resources) +- ✅ Format passes + +**Commands:** +```bash +npm run check +npm run format +``` + +--- + +### I8 – PhotoRatingWidget Component (Details Drawer) ✅ +**Estimated:** 90 minutes +**Dependencies:** I7 +**Status:** Complete + +**Deliverables:** +- [x] Component: `resources/js/components/gallery/photoModule/PhotoRatingWidget.vue` + - [x] Props: photoId, statistics, currentUserRating + - [x] State: selected_rating, hover_rating, loading + - [x] Use PrimeVue half-star icons (Q001-13): pi-star, pi-star-fill, pi-star-half-fill + - [x] Render buttons 0-5 with cumulative star display + - [x] No tooltips (Q001-15) + - [x] Disable buttons when loading (Q001-10) + - [x] Wait for server response (Q001-17) + - [x] Methods: handleRatingClick, handleMouseEnter, handleMouseLeave +- [x] Toast notifications for success/error states +- [x] Display average rating when metrics enabled + +**Exit Criteria:** +- ✅ Component renders +- ✅ Handles clicks +- ✅ Shows loading/success/error states +- ✅ TypeScript passes +- ✅ Format passes + +**Commands:** +```bash +npm run check +npm run format +``` + +**Key Patterns:** +- Q001-13: PrimeVue icons (pi-star, pi-star-fill, pi-star-half, pi-star-half-fill) +- Q001-10: Disable stars during API call +- Q001-17: No optimistic updates +- Q001-15: No tooltips + +--- + +### I9 – Integrate PhotoRatingWidget into PhotoDetails ✅ +**Estimated:** 60 minutes +**Dependencies:** I6, I8 +**Status:** Complete + +**Deliverables:** +- [x] Update `resources/js/components/drawers/PhotoDetails.vue` + - [x] Import PhotoRatingWidget + - [x] Add section below statistics + - [x] Pass props from photo resource (photoId, statistics, currentUserRating) + - [x] Rating updates handled by component (updates photoStore.photo) +- [x] TypeScript type checking passes + +**Exit Criteria:** +- ✅ Rating widget displays correctly in PhotoDetails +- ✅ All interactions work (handled by PhotoRatingWidget component) +- ✅ TypeScript passes + +**Commands:** +```bash +npm run check +npm run format +npm run dev # Manual testing +``` + +--- + +### I9a – ThumbRatingOverlay Component ✅ +**Estimated:** 90 minutes +**Dependencies:** I7, I8 +**Status:** Complete + +**Deliverables:** +- [x] Component: `resources/js/components/gallery/albumModule/thumbs/ThumbRatingOverlay.vue` + - [x] Props: currentUserRating, compact + - [x] State: hover_rating (local state, read-only display) + - [x] Use PrimeVue star icons (pi-star, pi-star-fill) + - [x] Backdrop blur background, compact layout + - [x] CSS: opacity-0 group-hover:opacity-100 transition + - [x] Positioned top-right with absolute positioning + - [x] No tooltips (Q001-15) + - [x] Read-only display (no click handlers) +- [x] Compact star design (cumulative filled display) +- [x] TypeScript type checking passes + +**Exit Criteria:** +- ✅ Component works in isolation +- ✅ Hover transitions work +- ✅ TypeScript passes + +**Commands:** +```bash +npm run check +npm run format +``` + +**Key Patterns:** +- Q001-04: Desktop only (md: breakpoint) +- Q001-13: PrimeVue half-star icons +- Q001-10: Loading state disables buttons + +--- + +### I9b – Integrate ThumbRatingOverlay into PhotoThumb ✅ +**Estimated:** 60 minutes +**Dependencies:** I9a +**Status:** Complete + +**Deliverables:** +- [x] Update `resources/js/components/gallery/albumModule/thumbs/PhotoThumb.vue` + - [x] Import ThumbRatingOverlay + - [x] Position at top-right of thumbnail + - [x] Pass current_user_rating prop + - [x] Pass compact prop for compact mode +- [x] Component integrated and rendering +- [x] TypeScript checks pass + +**Exit Criteria:** +- ✅ Rating overlay displays on thumbnails +- ✅ Shows hover transition in non-compact mode +- ✅ TypeScript validation passes + +**Commands:** +```bash +npm run check +npm run format +npm run dev +``` + +--- + +### I9c – PhotoRatingOverlay Component (Full Photo) ✅ +**Estimated:** 90 minutes +**Dependencies:** I7, I8 +**Status:** Complete + +**Deliverables:** +- [x] Component: `resources/js/components/gallery/photoModule/PhotoRatingOverlay.vue` + - [x] Display-only overlay (no interactive rating) + - [x] Shows user's current rating only + - [x] Top-right positioning with text-shadow + - [x] Uses PrimeVue star icons (pi-star, pi-star-fill) + - [x] Conditional rendering based on rating existence + - [x] Respects isRatingEnabled config placeholder +- [x] Component created and styled +- [x] TypeScript checks pass + +**Exit Criteria:** +- ✅ Component displays user rating on full photo view +- ✅ Positioning and styling correct +- ✅ TypeScript validation passes + +**Commands:** +```bash +npm run check +npm run format +``` + +**Key Patterns:** +- Q001-01: Bottom-center positioning +- Q001-02: 3-second auto-hide +- Q001-04: Desktop only +- Q001-10: Loading state pattern +- Q001-13: PrimeVue half-star icons +- Q001-15: No tooltips +- Q001-17: Wait for server +- Q001-18: Always show + +--- + +### I9d – Integrate PhotoRatingOverlay into PhotoPanel ✅ +**Estimated:** 60 minutes +**Dependencies:** I9c +**Status:** Complete + +**Deliverables:** +- [x] Update `resources/js/components/gallery/photoModule/PhotoPanel.vue` + - [x] Import PhotoRatingOverlay + - [x] Position after Overlay component + - [x] Always visible when rating exists (no hover required) +- [x] Component integrated and rendering +- [x] TypeScript checks pass + +**Exit Criteria:** +- ✅ Rating overlay displays on full-size photo view +- ✅ Positioning correct +- ✅ TypeScript validation passes + +**Commands:** +```bash +npm run check +npm run format +npm run dev +``` + +--- + +### I12a – Config Settings for Rating Visibility ✅ +**Estimated:** 60 minutes +**Dependencies:** I8, I9a, I9c +**Status:** Complete + +**Deliverables:** +- [x] Backend: Migration to add 6 config rows (Q001-11) + - [x] `ratings_enabled` (bool, default: true) - master switch + - [x] `rating_show_avg_in_details` (bool, default: true) + - [x] `rating_show_avg_in_photo_view` (bool, default: true) + - [x] `rating_photo_view_mode` (enum: always|hover|never, default: hover) - using VisibilityType enum + - [x] `rating_show_avg_in_album_view` (bool, default: true) + - [x] `rating_album_view_mode` (enum: always|hover|never, default: hover) - using VisibilityType enum + - [x] Update `/Photo::rate` to check `ratings_enabled` (throws ConfigurationException if disabled) + - [x] Use BaseConfigMigration pattern + - [x] Created VisibilityType enum for type safety +- [x] Frontend: Add to Lychee store + - [x] Add 6 settings to LycheeState + - [x] TypeScript types auto-generated via typescript:transform + - [x] Properties populated in load() action +- [x] Update components to respect settings + - [x] PhotoRatingWidget: Check `ratings_enabled` and `rating_show_avg_in_details`, metrics_enabled + - [x] ThumbRatingOverlay: Check `ratings_enabled`, `rating_show_avg_in_album_view`, and `rating_album_view_mode` + - [x] PhotoRatingOverlay: Check `ratings_enabled`, `rating_show_avg_in_photo_view`, and `rating_photo_view_mode` +- [x] InitConfig resource exposes all 6 settings +- [x] All quality gates pass (PHPStan, php-cs-fixer, TypeScript, Prettier) + +**Exit Criteria:** +- ✅ All 6 settings implemented +- ✅ Components respect settings +- ✅ Defaults applied +- ✅ Tests pass + +**Commands:** +```bash +npm run check +npm run format +php artisan test +``` + +**Key Patterns:** +- Q001-11: Independent ratings_enabled setting +- Q001-12: Hide all when metrics disabled +- Q001-25: Sensible defaults, no backfill + +--- + +## Documentation & Quality Tasks (Increments I12, I13) + +### I12 – Documentation & Knowledge Map Updates ✅ +**Estimated:** 45 minutes +**Dependencies:** All implementation complete +**Status:** Complete + +**Deliverables:** +- [x] Update `docs/specs/3-reference/database-schema.md` + - [x] Add PhotoRating model with fields, constraints, and relationships + - [x] Update Photo model to include PhotoRating and PhotoStatistics relationships + - [x] Update User model to include PhotoRating relationship +- [x] Update `docs/specs/4-architecture/roadmap.md` + - [x] Move Feature 001 from Active to Completed + - [x] Record completion date (2025-12-27) + - [x] Add feature notes + +**Exit Criteria:** +- ✅ All documentation updated and accurate + +--- + +### I13 – Final Quality Gate & Cleanup ✅ +**Estimated:** 60 minutes +**Dependencies:** All increments complete +**Status:** Complete + +**Deliverables:** +- [x] Run full PHP quality gate + - [x] `vendor/bin/php-cs-fixer fix` - 0 files changed, all formatted + - [x] `php artisan test` - 21/21 rating tests passing (1090 assertions) + - [x] `make phpstan` - No errors +- [x] Run full frontend quality gate + - [x] `npm run format` - All files properly formatted + - [x] `npm run check` - TypeScript validation passed +- [x] All tests passing: + - PhotoRatingConcurrencyTest: 4/4 passed + - PhotoRatingIntegrationTest: 5/5 passed + - SetPhotoRatingRequestTest: 12/12 passed + +**Exit Criteria:** +- ✅ All quality gates pass +- ✅ Feature ready for review/commit + +**Commands:** +```bash +vendor/bin/php-cs-fixer fix +npm run format +php artisan test +npm run check +make phpstan +``` + +--- + +### I14 – Performance Optimization (N+1 Query Fix) ⏳ +**Estimated:** 90 minutes +**Dependencies:** I6 (PhotoResource) +**Status:** Not started +**Priority:** High - Performance issue + +**Problem:** +Currently, the `PhotoResource` fetches the current user's rating individually for each photo, causing an N+1 query problem when loading collections (albums, search results, etc.). + +**Deliverables:** +- [ ] Add eager loading capability to PhotoResource + - [ ] Detect if ratings are already loaded on collection + - [ ] Add `loadMissing(['ratings'])` when needed + - [ ] Filter ratings to current user in memory instead of separate query +- [ ] Update controllers to eager load ratings + - [ ] AlbumController: `$photos->load('ratings')` before resource creation + - [ ] SearchController: Eager load on photo collections + - [ ] Any other endpoints returning photo collections +- [ ] Performance testing + - [ ] Measure queries before/after with 100 photos + - [ ] Verify N+1 eliminated using Laravel Debugbar or Telescope + - [ ] Document query count improvement +- [ ] Add test for eager loading + - [ ] Test that collections don't trigger N+1 + - [ ] Test that single photo still works + +**Implementation Strategy:** +```php +// In PhotoResource constructor: +if (!$photo->relationLoaded('ratings')) { + $photo->loadMissing('ratings'); +} + +// Then filter in memory: +$this->current_user_rating = $photo->ratings + ->where('user_id', Auth::id()) + ->first() + ?->rating; +``` + +**Alternative Approach (More Efficient):** +```php +// In controllers returning collections: +$photos = Photo::with(['ratings' => function ($query) { + $query->where('user_id', Auth::id()); +}])->get(); + +// In PhotoResource: +$this->current_user_rating = $photo->ratings->first()?->rating; +``` + +**Exit Criteria:** +- ✅ N+1 query eliminated for photo collections +- ✅ Single photo endpoints still work correctly +- ✅ Performance improvement documented +- ✅ Tests verify no regression + +**Commands:** +```bash +php artisan test --filter PhotoResource +make phpstan +vendor/bin/php-cs-fixer fix +``` + +--- + +## Task Summary by Category + +### Backend (480 minutes / 8 hours) +- I1: Migrations (60m) +- I2: PhotoRating Model (60m) +- I3: Statistics Model (45m) +- I4: Request Validation (60m) +- I5: Controller Logic (90m) +- I6: PhotoResource (60m) +- I10: Error Handling (60m) +- I11: Concurrency Tests (60m) + +### Frontend (540 minutes / 9 hours) +- I7: Service Layer (45m) +- I8: PhotoRatingWidget (90m) +- I9: Integration - Details (60m) +- I9a: ThumbRatingOverlay (90m) +- I9b: Integration - Thumb (60m) +- I9c: PhotoRatingOverlay (90m) +- I9d: Integration - PhotoPanel (60m) +- I12a: Config Settings (60m) + +### Documentation & Quality (105 minutes / 1.75 hours) +- I12: Documentation (45m) +- I13: Quality Gate (60m) + +### Total: 1125 minutes (~18.75 hours) + +--- + +## Dependencies Graph + +``` +I1 (Migrations) +├── I2 (PhotoRating Model) +├── I3 (Statistics Model) +└── I5 (PhotoController) + ├── I6 (PhotoResource) + │ └── I9 (Integration - Details) + ├── I7 (Service Layer) + │ ├── I8 (PhotoRatingWidget) + │ │ ├── I9 (Integration - Details) + │ │ └── I12a (Config Settings) + │ ├── I9a (ThumbRatingOverlay) + │ │ ├── I9b (Integration - Thumb) + │ │ └── I12a (Config Settings) + │ └── I9c (PhotoRatingOverlay) + │ ├── I9d (Integration - PhotoPanel) + │ └── I12a (Config Settings) + ├── I10 (Error Handling) + └── I11 (Concurrency Tests) + +I4 (Request Validation) → I5 (PhotoController) + +All → I12 (Documentation) → I13 (Quality Gate) +``` + +--- + +## Critical Path + +1. I1 → I2 → I5 → I6 → I7 → I8 → I9 (Backend foundation → Service → Widget → Integration) +2. I9a → I9b (Thumbnail overlay) +3. I9c → I9d (Photo overlay) +4. I12a (Config settings) +5. I10, I11 (Testing) +6. I12, I13 (Documentation & Quality) + +--- + +## Open Questions Resolved + +All 25 open questions (Q001-01 through Q001-25) have been resolved. Key decisions are documented in the [plan.md](plan.md) "Key Implementation Patterns" section. + +--- + +*Last updated: 2025-12-27* diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md index 281f8d6e0ba..4208da342df 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -10,7 +10,1120 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon ## Question Details -_No question details currently tracked._ +### ~~Q001-07: Statistics Record Creation Strategy~~ ✅ RESOLVED + +**Decision:** Option A - firstOrCreate in transaction +**Rationale:** Atomic operation with no race conditions, Laravel handles duplicate creation attempts automatically, simple implementation. +**Updated in spec:** Implementation plan I5 + +--- + +### ~~Q001-08: Transaction Rollback Error Handling~~ ✅ RESOLVED + +**Decision:** Option B - 409 Conflict for transaction errors +**Rationale:** More semantic HTTP status, indicates temporary issue that suggests retry, clearer to frontend. +**Updated in spec:** Implementation plan I5, I10 + +--- + +### ~~Q001-09: N+1 Query Performance for user_rating~~ ✅ RESOLVED + +**Decision:** Option A - Eager load with closure in controller +**Rationale:** Standard Laravel pattern, single additional query for all photos, no global scope side effects. +**Updated in spec:** Implementation plan I6 + +--- + +### ~~Q001-10: Concurrent Update Debouncing (Rapid Clicks)~~ ✅ RESOLVED + +**Decision:** Option A - Disable stars during API call +**Rationale:** Simple implementation, prevents concurrent requests, clear visual feedback with loading state. +**Updated in spec:** Implementation plan I8, I9a, I9c + +--- + +### ~~Q001-11: Metrics Disabled Behavior (Can Still Rate?)~~ ✅ RESOLVED + +**Decision:** Option C - Admin setting controls independently +**Rationale:** Granular control allows enabling rating without showing aggregates, future-proof configuration. +**Updated in spec:** New config setting needed (separate `ratings_enabled` from `metrics_enabled`) + +--- + +### ~~Q001-12: Rating Display When Metrics Disabled~~ ✅ RESOLVED + +**Decision:** Option B - Hide all rating data when metrics disabled +**Rationale:** Fully consistent with metrics disabled setting, simplest implementation, respects admin preference. +**Updated in spec:** UI components conditional rendering + +--- + +### ~~Q001-13: Half-Star Display for Fractional Averages~~ ✅ RESOLVED + +**Decision:** Option B - Half-star display using PrimeVue icons +**Rationale:** PrimeVue provides pi-star, pi-star-fill, pi-star-half, pi-star-half-fill icons. More precise visual representation, common rating pattern. +**Updated in spec:** UI mockups, component implementation uses PrimeVue star icons + +--- + +### ~~Q001-14: Overlay Persistence on Active Interaction~~ ✅ RESOLVED + +**Decision:** Option A - Persist while loading, then restart auto-hide timer +**Rationale:** User sees confirmation (success toast + updated rating), natural interaction flow. +**Updated in spec:** Implementation plan I9c, PhotoRatingOverlay behavior + +--- + +### ~~Q001-15: Rating Tooltip/Label Clarity~~ ✅ RESOLVED + +**Decision:** Option C - No labels/tooltips (stars are self-evident) +**Rationale:** Cleanest UI, stars are universal rating symbol, keeps overlays compact. +**Updated in spec:** UI components (no tooltip implementation needed) + +--- + +### ~~Q001-16: Accessibility (Keyboard Navigation, ARIA)~~ ✅ RESOLVED + +**Decision:** Option C - Defer to post-MVP +**Rationale:** Ship faster with basic implementation, gather user feedback first, can enhance accessibility later. +**Updated in spec:** Out of scope (deferred enhancement) + +--- + +### ~~Q001-17: Optimistic UI Updates vs Server Confirmation~~ ✅ RESOLVED + +**Decision:** Option A - Wait for server confirmation +**Rationale:** Always shows accurate server state, clear error handling, no phantom updates. +**Updated in spec:** Implementation plan I8, I9a, I9c (loading state pattern) + +--- + +### ~~Q001-18: Rating Count Threshold for Display~~ ✅ RESOLVED + +**Decision:** Option A - Always show rating, regardless of count +**Rationale:** Transparent, simpler logic, users can judge significance from count displayed. +**Updated in spec:** UI components (no threshold logic needed) + +--- + +### ~~Q001-19: Telemetry Event Granularity~~ ✅ RESOLVED + +**Decision:** No telemetry events / analytics +**Rationale:** Feature does not include telemetry or analytics tracking. +**Updated in spec:** Remove telemetry events from FR-001-01, FR-001-02, FR-001-03 + +--- + +### ~~Q001-20: Rating Analytics/Trending Features~~ ✅ RESOLVED + +**Decision:** Option B - Implement minimally for current scope +**Rationale:** Follows YAGNI principle, simpler initial implementation, faster to ship. +**Updated in spec:** Out of scope (no future analytics preparation) + +--- + +### ~~Q001-21: Album Aggregate Rating Display~~ ✅ RESOLVED + +**Decision:** Option A - Defer to future feature +**Rationale:** Keeps current feature focused, can design properly later with user feedback on photo ratings. +**Updated in spec:** Out of scope, potential future Feature 00X + +--- + +### ~~Q001-22: Rating Export in Photo Backup~~ ✅ RESOLVED + +**Decision:** Option C - No export (ratings are ephemeral/server-side only) +**Rationale:** Simpler export logic, smaller export files. +**Updated in spec:** Out of scope (no export functionality) + +--- + +### ~~Q001-23: Rating Notification to Photo Owner~~ ✅ RESOLVED + +**Decision:** Option A - Defer to future feature (notifications system) +**Rationale:** Keeps feature scope focused, requires notifications infrastructure that may not exist yet. +**Updated in spec:** Out of scope (deferred to future notifications feature) + +--- + +### ~~Q001-24: Statistics Recalculation Artisan Command~~ ✅ RESOLVED + +**Decision:** Option B - No command, rely on transaction integrity +**Rationale:** Trust atomic transactions to maintain consistency, simpler implementation. +**Updated in spec:** Out of scope (no artisan command) + +--- + +### ~~Q001-25: Migration Strategy for Existing Installations~~ ✅ RESOLVED + +**Decision:** Option A - Migration adds columns with defaults, no backfill +**Rationale:** Clean state (accurate: no ratings yet), fast migration, no assumptions about historical data. +**Updated in spec:** Implementation plan I1 (migrations with default values) + +--- + +### ~~Q001-05: Authorization Model for Rating~~ ✅ RESOLVED + +**Decision:** Option B - Read access (anyone who can view can rate) +**Rationale:** Follows standard rating system patterns. Rating is a lightweight engagement action similar to favoriting, not a privileged edit operation. Makes ratings more accessible and useful. +**Updated in spec:** FR-001-01, NFR-001-04 + +--- + +### ~~Q001-06: Rating Removal HTTP Status Code~~ ✅ RESOLVED + +**Decision:** 200 OK (idempotent behavior) +**Rationale:** Removing a non-existent rating is a no-op and should return success (200 OK) rather than 404 error. This makes the endpoint idempotent and simpler to use. +**Updated in spec:** FR-001-02 + +--- + +### ~~Q001-01: Full-size Photo Overlay Positioning~~ ✅ RESOLVED + +**Decision:** Option A - Bottom-center +**Rationale:** Centered position is more discoverable and doesn't compete with Dock buttons. Symmetrical with metadata overlay below. +**Updated in spec:** FR-001-10, UI mockup section 2, implementation plan I9c/I9d + +--- + +### ~~Q001-02: Auto-hide Timer Duration~~ ✅ RESOLVED + +**Decision:** Option A - 3 seconds +**Rationale:** Standard UX pattern, balanced duration (not too fast, not too slow). +**Updated in spec:** FR-001-10, UI mockup section 2, implementation plan I9c + +--- + +### ~~Q001-03: Rating Removal Button Placement~~ ✅ RESOLVED + +**Decision:** Option A - Inline [0] button +**Rationale:** Consistent button pattern, simple implementation, shown as "×" or "Remove" for clarity. +**Updated in spec:** FR-001-09, UI mockup section 1, implementation plan I9a + +--- + +### ~~Q001-04: Overlay Visibility on Mobile Devices~~ ✅ RESOLVED + +**Decision:** Option A - Details drawer only on mobile +**Rationale:** Follows existing Lychee pattern (overlays are desktop-only), simple and consistent experience. +**Updated in spec:** FR-001-09, FR-001-10, UI mockup sections 1-2, implementation plan I9a/I9c + +--- + +### ~~Q001-01: Full-size Photo Overlay Positioning~~ (ARCHIVED) + +**Context:** When hovering over the lower area of a full-size photo, the rating overlay can be positioned in different locations. The spec currently presents two options. + +**Question:** Which positioning approach should we use for the full-size photo rating overlay? + +**Options (ordered by preference):** + +**Option A: Bottom-center (Recommended)** +- **Position:** Horizontally centered, positioned above the metadata overlay (title/EXIF) +- **Layout:** `★★★★☆ 4.2 (15) Your rating: ★★★★☆ [0][1][2][3][4][5]` +- **Pros:** + - Centered position is intuitive and balanced + - Doesn't compete with Dock buttons for space + - More visible and discoverable + - Symmetrical with metadata overlay below it +- **Cons:** + - May obstruct central portion of photo + - Wider horizontal space required + +**Option B: Bottom-right (near Dock buttons)** +- **Position:** Bottom-right corner, adjacent to existing Dock action buttons +- **Layout:** Compact vertical or horizontal near Dock +- **Pros:** + - Groups with other photo actions (Dock buttons) + - Consistent with action button placement pattern + - Less obstruction of photo center +- **Cons:** + - May crowd the Dock button area + - Less discoverable (user might not look at corner) + - Asymmetrical with metadata overlay (which is bottom-left) + +**Impact:** Medium - affects UX discoverability and visual balance, but either option is functional. + +--- + +### Q001-02: Auto-hide Timer Duration + +**Context:** The full-size photo rating overlay auto-hides after a period of inactivity to avoid obstructing the photo view. + +**Question:** What duration should the auto-hide timer be set to? + +**Options (ordered by preference):** + +**Option A: 3 seconds (Recommended)** +- **Duration:** Overlay fades out after 3 seconds of no mouse movement +- **Pros:** + - Short enough to not be annoying + - Long enough for user to read and interact + - Common UX pattern for transient overlays +- **Cons:** + - May feel rushed for slower users + - Might hide before user finishes reading + +**Option B: 5 seconds** +- **Duration:** Overlay fades out after 5 seconds of no mouse movement +- **Pros:** + - More time for users to read and decide + - Less pressure to act quickly +- **Cons:** + - Longer obstruction of photo view + - May feel sluggish + +**Option C: Configurable (with 3s default)** +- **Duration:** User setting for auto-hide duration (1-10 seconds) +- **Pros:** + - User preference accommodated + - Accessible for users with different needs +- **Cons:** + - Added complexity (settings UI, store management) + - Deferred to post-MVP + +**Option D: No auto-hide (manual dismiss only)** +- **Duration:** Overlay persists until user moves mouse away from lower area +- **Pros:** + - No time pressure + - User controls when it disappears +- **Cons:** + - Overlay may linger and obstruct photo + - Less elegant UX + +**Impact:** Medium - affects user experience and perception of polish, but any reasonable duration works. + +--- + +### Q001-03: Rating Removal Button Placement + +**Context:** Users can remove their rating by selecting "0". The UI design needs to clarify how this is presented. + +**Question:** How should the "remove rating" (0) option be presented in the UI? + +**Options (ordered by preference):** + +**Option A: Inline button [0] before stars (Recommended)** +- **Layout:** `[0] [1] [2] [3] [4] [5]` with 0 shown as "×" or "Remove" +- **Pros:** + - Consistent with the button pattern + - Clear that 0 is a special action (remove) + - Simple implementation (same component pattern) +- **Cons:** + - May be confused with a rating of zero + - Takes up space in compact overlays + +**Option B: Separate "Clear rating" button** +- **Layout:** `[1] [2] [3] [4] [5] [Clear ×]` +- **Pros:** + - Visually distinct from rating action + - Clearer intent (remove vs rate) + - Reduces accidental removal +- **Cons:** + - Additional UI element + - Less compact for overlays + +**Option C: Right-click or long-press to remove** +- **Interaction:** Click star to rate, right-click/long-press to remove +- **Pros:** + - No additional UI needed + - Clean visual design +- **Cons:** + - Not discoverable (hidden interaction) + - Accessibility concerns + - Mobile long-press may be awkward + +**Impact:** Low - all options are functional, mainly affects visual design and user discovery. + +--- + +### Q001-04: Overlay Visibility on Mobile Devices + +**Context:** The current spec hides rating overlays on mobile (below md: breakpoint) because hover interactions don't work well on touch devices. Users can still rate via the details drawer. + +**Question:** Should we provide any rating interaction on mobile beyond the details drawer? + +**Options (ordered by preference):** + +**Option A: Details drawer only on mobile (Recommended)** +- **Behavior:** No overlays on mobile, rating only via PhotoDetails drawer +- **Pros:** + - Simple, consistent experience + - No awkward touch interaction patterns needed + - Cleaner thumbnail grid (no overlay clutter) + - Follows existing Lychee mobile pattern (overlays are desktop-only) +- **Cons:** + - Requires opening details drawer to rate + - Less convenient for quick ratings + +**Option B: Tap-to-show overlay on thumbnails** +- **Behavior:** Single tap shows overlay (without opening photo), tap star to rate, tap outside to dismiss +- **Pros:** + - Quick access to rating on mobile + - No need to open details drawer +- **Cons:** + - Conflicts with tap-to-open-photo gesture + - Requires double-tap or long-press (poor UX) + - Added complexity in touch event handling + +**Option C: Always-visible compact rating on thumbnails (mobile)** +- **Behavior:** Small rating display (stars or number) always visible on thumbnails on mobile +- **Pros:** + - Ratings always visible at a glance + - Tap star to rate directly +- **Cons:** + - Clutters thumbnail grid + - Inconsistent with desktop (hover-only) + - May obscure thumbnail image + +**Impact:** Medium - affects mobile user experience, but details drawer provides full fallback. + +--- + +### Q001-07: Statistics Record Creation Strategy + +**Context:** When a user rates a photo for the first time, the `photo_statistics` record may not exist yet. The implementation must handle this gracefully. + +**Question:** How should we ensure the statistics record exists when creating the first rating? + +**Options (ordered by preference):** + +**Option A: firstOrCreate in transaction (Recommended)** +- **Approach:** Use `PhotoStatistics::firstOrCreate(['photo_id' => $photo_id], [...defaults])` within the transaction +- **Pros:** + - Atomic operation, no race condition + - Laravel handles duplicate creation attempts + - Simple implementation +- **Cons:** + - May create statistics record even if rating fails validation + - Extra query overhead + +**Option B: Check existence before rating** +- **Approach:** Check if statistics exists, create if missing before rating transaction +- **Pros:** + - Explicit control flow + - Clear error handling +- **Cons:** + - Two separate operations (not atomic) + - Race condition if two users rate simultaneously + - More complex code + +**Option C: Database trigger** +- **Approach:** Create database trigger to auto-create statistics record on photo insert +- **Pros:** + - Guarantees statistics always exists + - No application logic needed +- **Cons:** + - Adds database complexity + - Migration complexity for existing photos + - Not Lychee's pattern (application-level logic preferred) + +**Impact:** High - affects data integrity and implementation complexity + +--- + +### Q001-08: Transaction Rollback Error Handling + +**Context:** When a database transaction fails (e.g., deadlock, constraint violation), the spec doesn't clarify what error should be returned to the user. + +**Question:** How should we handle transaction failures in the rating endpoint? + +**Options (ordered by preference):** + +**Option A: 500 Internal Server Error with generic message (Recommended)** +- **Response:** HTTP 500, `{"message": "Unable to save rating. Please try again."}` +- **Pros:** + - Doesn't expose database implementation details + - Standard error handling pattern + - User-friendly message +- **Cons:** + - Less specific for debugging + - May retry without fixing underlying issue + +**Option B: 409 Conflict for transaction errors** +- **Response:** HTTP 409, `{"message": "Rating conflict. Please refresh and try again."}` +- **Pros:** + - More semantic (conflict suggests retry) + - Indicates temporary issue +- **Cons:** + - 409 typically used for optimistic locking conflicts + - May confuse frontend logic + +**Option C: Log error, retry transaction automatically** +- **Approach:** Catch deadlock exceptions, retry transaction 2-3 times before failing +- **Pros:** + - Transparent to user + - Handles temporary deadlocks gracefully +- **Cons:** + - Added complexity + - May mask underlying database issues + - Increased latency + +**Impact:** High - affects error handling strategy and user experience + +--- + +### Q001-09: N+1 Query Performance for user_rating + +**Context:** PhotoResource includes `user_rating` field by querying `$this->ratings()->where('user_id', auth()->id())->value('rating')`. When loading many photos (album grid), this creates N+1 query problem. + +**Question:** How should we optimize user_rating loading for photo collections? + +**Options (ordered by preference):** + +**Option A: Eager load with closure in controller (Recommended)** +- **Implementation:** + ```php + $photos->load(['ratings' => fn($q) => $q->where('user_id', auth()->id())]); + ``` +- **Pros:** + - Single additional query for all photos + - Standard Laravel pattern + - No PhotoResource changes needed +- **Cons:** + - Must remember to eager load in every controller method + - Easy to forget and create N+1 + +**Option B: Global scope on Photo model** +- **Implementation:** Add global scope to always eager load current user's rating +- **Pros:** + - Automatic, no controller changes needed + - Consistent across all queries +- **Cons:** + - Always loads ratings even when not needed + - Performance overhead for unauthenticated users + - Global scopes can have unexpected side effects + +**Option C: Separate endpoint for ratings** +- **Implementation:** Load photos without ratings, fetch ratings separately via `/api/photos/{ids}/ratings` +- **Pros:** + - Decoupled data loading + - Can defer ratings until needed +- **Cons:** + - Two API calls required + - More complex frontend logic + - Increased latency + +**Impact:** High - affects performance for album views with many photos + +--- + +### Q001-10: Concurrent Update Debouncing (Rapid Clicks) + +**Context:** If a user rapidly clicks different star values, multiple concurrent API requests may be sent. This could cause race conditions or display inconsistencies. + +**Question:** Should we debounce or throttle rapid rating changes in the UI? + +**Options (ordered by preference):** + +**Option A: Disable stars during API call (Recommended)** +- **Behavior:** Set `loading = true`, disable all star buttons until API returns +- **Pros:** + - Simple implementation + - Prevents concurrent requests + - Clear visual feedback (loading state) +- **Cons:** + - User must wait for each rating to complete + - Slower if user wants to correct mistake + +**Option B: Debounce rating submissions (300ms)** +- **Behavior:** Wait 300ms after last click before sending API request, cancel pending requests +- **Pros:** + - Allows user to change mind quickly + - Reduces API calls for rapid clicks +- **Cons:** + - Delayed feedback + - More complex implementation (cancel logic) + - May feel sluggish + +**Option C: Queue requests, send last value only** +- **Behavior:** Queue rating changes, send only most recent value when previous request completes +- **Pros:** + - Always saves final user choice + - No wasted API calls +- **Cons:** + - Complex state management + - User may see intermediate states that don't persist + +**Impact:** High - affects UX responsiveness and data consistency + +--- + +### Q001-11: Metrics Disabled Behavior (Can Still Rate?) + +**Context:** The spec says rating data is hidden when `metrics_enabled` config is false, but doesn't clarify if users can still submit ratings when metrics are disabled. + +**Question:** When metrics are disabled, should users still be able to rate photos? + +**Options (ordered by preference):** + +**Option A: Yes, rating functionality always available (Recommended)** +- **Behavior:** Users can rate, but aggregates/counts are hidden in UI. Data is still stored. +- **Pros:** + - Consistent user experience + - Data collection continues even if display is disabled + - Easy to re-enable metrics later with existing data +- **Cons:** + - May confuse users (why can I rate if I can't see ratings?) + - Data stored but not shown + +**Option B: No, disable rating when metrics disabled** +- **Behavior:** Hide all rating UI and disable `/Photo::rate` endpoint when metrics disabled +- **Pros:** + - Consistent (if metrics off, ratings off) + - Respects privacy/metrics setting fully +- **Cons:** + - Loss of data collection + - Hard to re-enable later (no historical data) + - Inconsistent with favorites (favorites work when metrics disabled) + +**Option C: Admin setting controls independently** +- **Behavior:** Separate `ratings_enabled` config independent of `metrics_enabled` +- **Pros:** + - Granular control + - Can enable rating without showing aggregates +- **Cons:** + - More configuration complexity + - May confuse admins + +**Impact:** High - affects feature scope and user experience + +--- + +### Q001-12: Rating Display When Metrics Disabled + +**Context:** FR-001-04 says rating data is shown "when metrics are enabled," but spec doesn't clarify if user's own rating is shown when metrics are disabled. + +**Question:** When metrics are disabled, should the UI show the user's own rating (even if aggregates are hidden)? + +**Options (ordered by preference):** + +**Option A: Show user's own rating regardless of metrics setting (Recommended)** +- **Behavior:** User sees their own rating stars highlighted, but no aggregate average/count +- **Pros:** + - User feedback on their own action + - Doesn't expose community metrics (privacy preserved) + - Consistent with user-centric data (my data vs community data) +- **Cons:** + - Slightly inconsistent with "metrics disabled" (rating is a metric) + +**Option B: Hide all rating data when metrics disabled** +- **Behavior:** No rating display at all, including user's own +- **Pros:** + - Fully consistent with metrics disabled + - Simplest implementation +- **Cons:** + - Poor UX (user can't see what they rated) + - Feels broken ("I clicked 4 stars, where did it go?") + +**Impact:** Medium - affects UX when metrics are disabled + +--- + +### Q001-13: Half-Star Display for Fractional Averages + +**Context:** Spec stores rating_avg as decimal(3,2), allowing fractional values like 4.33. UI mockups show full/empty stars only (no half-stars). + +**Question:** Should we display half-stars for fractional average ratings? + +**Options (ordered by preference):** + +**Option A: Full stars only, round to nearest integer (Recommended)** +- **Display:** 4.33 avg → ★★★★☆ (4 stars), show "4.33" as text next to stars +- **Pros:** + - Simpler UI implementation + - Clear visual (full or empty) + - Numeric value still shows precision +- **Cons:** + - Visual representation less precise + +**Option B: Half-star display for .25-.74 range** +- **Display:** 4.33 avg → ★★★★⯨ (4.5 stars visually), show "4.33" as text +- **Pros:** + - More precise visual representation + - Common rating pattern (Amazon, IMDb) +- **Cons:** + - More complex implementation (half-star icon, rounding logic) + - May not match user's mental model (users rate 1-5, not 1-10) + +**Option C: Gradient fill for precise fractional display** +- **Display:** 4.33 avg → ★★★★⯨ (4th star 33% filled) +- **Pros:** + - Exact visual representation + - Visually interesting +- **Cons:** + - Complex implementation (SVG/CSS gradients) + - May be hard to read at small sizes + - Uncommon pattern (users may not understand) + +**Impact:** Medium - affects UI polish and clarity + +--- + +### Q001-14: Overlay Persistence on Active Interaction + +**Context:** PhotoRatingOverlay (full photo) auto-hides after 3 seconds of inactivity. Spec says "persists if mouse over overlay itself," but doesn't clarify behavior when user is actively clicking/interacting. + +**Question:** Should the overlay stay visible while the user is actively interacting with the rating stars, even if they briefly move the mouse outside the overlay? + +**Options (ordered by preference):** + +**Option A: Persist while loading, then restart auto-hide timer (Recommended)** +- **Behavior:** After user clicks a star, overlay stays visible during API call (loading state), then restarts 3s auto-hide timer on success +- **Pros:** + - User sees confirmation (success toast + updated rating) + - Natural flow (interact → see result → overlay fades) +- **Cons:** + - May stay visible longer than expected + +**Option B: Auto-hide immediately after successful rating** +- **Behavior:** After rating succeeds, overlay fades out immediately (no 3s delay) +- **Pros:** + - Faster cleanup after action + - User sees toast notification for confirmation +- **Cons:** + - Abrupt (overlay disappears right after click) + - User may not see updated average + +**Option C: Persist until mouse leaves lower area entirely** +- **Behavior:** Overlay stays visible as long as mouse is in lower 20-30% zone, regardless of timer +- **Pros:** + - User has full control + - Overlay available for multiple rating changes +- **Cons:** + - May linger too long + - Obstructs photo view longer + +**Impact:** Medium - affects UX polish and expected behavior + +--- + +### Q001-15: Rating Tooltip/Label Clarity (What Are Stars?) + +**Context:** UI mockups don't show tooltips or ARIA labels explaining what the star rating means (1 = lowest, 5 = highest). + +**Question:** Should we add tooltips/labels to explain the star rating scale? + +**Options (ordered by preference):** + +**Option A: Hover tooltips on star buttons (Recommended)** +- **Implementation:** Each star button shows tooltip: "1 star", "2 stars", ... "5 stars" +- **Pros:** + - Self-explanatory on hover + - Accessible (screen reader friendly with aria-label) + - Doesn't clutter UI +- **Cons:** + - Requires tooltip implementation + - May be obvious to most users + +**Option B: Label text: "Rate 1-5 stars"** +- **Implementation:** Static text label above star buttons +- **Pros:** + - Always visible, no hover needed + - Clear scale indication +- **Cons:** + - Takes up space in compact overlays + - May be redundant (stars are intuitive) + +**Option C: No labels/tooltips (stars are self-evident)** +- **Implementation:** No additional labels, star icons only +- **Pros:** + - Cleanest UI + - Stars are universal rating symbol +- **Cons:** + - Accessibility concerns (screen reader users) + - New users may not understand scale + +**Impact:** Medium - affects accessibility and UX clarity + +--- + +### Q001-16: Accessibility (Keyboard Navigation, ARIA) + +**Context:** Spec doesn't specify keyboard navigation or ARIA attributes for rating components. + +**Question:** What accessibility features should be implemented for the rating UI? + +**Options (ordered by preference):** + +**Option A: Full WCAG 2.1 AA compliance (Recommended)** +- **Implementation:** + - Keyboard navigation: Tab to focus rating, Arrow keys to select star, Enter/Space to rate + - ARIA attributes: `role="radiogroup"`, `aria-label="Rate this photo"`, `aria-checked` on selected star + - Focus indicators: Visible outline on focused star + - Screen reader announcements: "4 stars selected, 15 total votes, average 4.2" +- **Pros:** + - Fully accessible to all users + - Meets legal/compliance requirements + - Better UX for keyboard users +- **Cons:** + - More implementation effort + - Testing complexity + +**Option B: Basic accessibility (tab focus, ARIA labels only)** +- **Implementation:** Tab to rating widget, click to rate, basic aria-labels +- **Pros:** + - Simpler implementation + - Covers most accessibility needs +- **Cons:** + - Not fully keyboard navigable + - May not meet WCAG AA + +**Option C: Defer to post-MVP** +- **Decision:** Launch with basic implementation, enhance accessibility later +- **Pros:** + - Faster to ship + - Can gather user feedback first +- **Cons:** + - Excludes users with disabilities + - Harder to retrofit later + - Potential compliance issues + +**Impact:** Medium - affects accessibility and inclusivity + +--- + +### Q001-17: Optimistic UI Updates vs Server Confirmation + +**Context:** Spec doesn't clarify whether UI should update optimistically (immediately on click) or wait for server confirmation. + +**Question:** Should the rating UI update optimistically or wait for API response? + +**Options (ordered by preference):** + +**Option A: Wait for server confirmation (Recommended)** +- **Behavior:** Show loading state on click, update UI only after API success +- **Pros:** + - Always shows accurate server state + - Clear error handling (revert on failure) + - No phantom updates +- **Cons:** + - Slower perceived responsiveness + - Requires loading state UI + +**Option B: Optimistic update, revert on error** +- **Behavior:** Update UI immediately on click, show error and revert if API fails +- **Pros:** + - Instant feedback, feels faster + - Better perceived performance +- **Cons:** + - Complex state management (revert logic) + - User may see incorrect state briefly + - Confusing if network is slow and revert happens seconds later + +**Option C: Hybrid (optimistic for user rating, wait for aggregate)** +- **Behavior:** Update user's star selection immediately, but wait for server to update average/count +- **Pros:** + - Fast feedback for user action + - Accurate aggregate display +- **Cons:** + - Split state management + - May show inconsistent state (user rating updated, aggregate unchanged) + +**Impact:** Medium - affects perceived performance and UX + +--- + +### Q001-18: Rating Count Threshold for Display + +**Context:** Spec doesn't specify if ratings should be hidden when count is very low (e.g., 1-2 ratings may not be statistically meaningful). + +**Question:** Should we hide average rating display until a minimum number of ratings exist? + +**Options (ordered by preference):** + +**Option A: Always show rating, regardless of count (Recommended)** +- **Display:** Show "★★★★★ 5.0 (1)" even for single rating +- **Pros:** + - Transparent, shows all data + - Simpler logic (no threshold) + - Users can judge significance from count +- **Cons:** + - Single ratings may be misleading (not representative) + - May encourage rating manipulation + +**Option B: Hide average until N >= 3 ratings** +- **Display:** Show "(3 ratings)" text only until 3+ ratings, then show average +- **Pros:** + - More statistically meaningful average + - Reduces impact of single outlier ratings +- **Cons:** + - Hides data from users + - Arbitrary threshold (why 3?) + - Users may be confused why they can't see average after rating + +**Option C: Show with disclaimer for low counts** +- **Display:** "★★★★★ 5.0 (1 rating)" with styling/tooltip: "Based on limited ratings" +- **Pros:** + - Shows data with context + - Users can make informed judgment +- **Cons:** + - More UI complexity + - May clutter compact overlays + +**Impact:** Medium - affects data presentation and perceived trustworthiness + +--- + +### Q001-19: Telemetry Event Granularity + +**Context:** Spec defines three telemetry events (photo.rated, photo.rating_updated, photo.rating_removed). These events overlap (updating is also rating). + +**Question:** Should we emit separate events for create vs update, or combine into one event? + +**Options (ordered by preference):** + +**Option A: Three separate events (as spec defines) (Recommended)** +- **Events:** `photo.rated` (new), `photo.rating_updated` (change), `photo.rating_removed` (delete) +- **Pros:** + - Granular analytics (can track rating changes separately from new ratings) + - Easier to query specific actions +- **Cons:** + - More event types to maintain + - Logic to determine which event to emit + +**Option B: Single event with action field** +- **Event:** `photo.rating_changed` with field `action: "created"|"updated"|"removed"` +- **Pros:** + - Simpler event schema + - Single event handler +- **Cons:** + - Less semantic + - Requires filtering by action field in analytics + +**Option C: Two events (rated/removed only)** +- **Events:** `photo.rated` (create or update), `photo.rating_removed` +- **Pros:** + - Simpler (updates are just "rated again") + - Matches user mental model (user doesn't distinguish create vs update) +- **Cons:** + - Can't track rating changes separately from new ratings + +**Impact:** Low - affects telemetry analytics, doesn't affect user experience + +--- + +### Q001-20: Rating Analytics/Trending Features + +**Context:** Spec explicitly excludes "advanced rating analytics or trends" from scope, but this may be a desirable future feature. + +**Question:** Should we design the schema and telemetry to support future analytics features (trending photos, rating distributions)? + +**Options (ordered by preference):** + +**Option A: Yes, design for extensibility (Recommended)** +- **Approach:** Include timestamps, consider adding indexes for common queries (ORDER BY rating_avg), design telemetry for time-series analysis +- **Pros:** + - Easier to add features later + - Better query performance from day 1 + - Minimal overhead now +- **Cons:** + - May add complexity that's never used + - YAGNI (You Aren't Gonna Need It) principle violation + +**Option B: No, implement minimally for current scope** +- **Approach:** Bare minimum schema/indexes for current requirements, add analytics support later if needed +- **Pros:** + - Simpler initial implementation + - Follows YAGNI principle + - Faster to ship +- **Cons:** + - May require schema changes later + - Migration complexity for existing data + +**Impact:** Low - affects future extensibility, not current functionality + +--- + +### Q001-21: Album Aggregate Rating Display + +**Context:** Spec excludes "album-level aggregate ratings" from scope, but users may expect to see album ratings in album grid view. + +**Question:** Should we display aggregate album ratings (average of all photo ratings in album)? + +**Options (ordered by preference):** + +**Option A: Defer to future feature (Recommended)** +- **Decision:** Not in scope for Feature 001, track as separate future feature (Feature 00X) +- **Pros:** + - Keeps current feature focused + - Can design properly later with user feedback on photo ratings +- **Cons:** + - Users may expect this feature + - More work to add later + +**Option B: Add to current feature scope** +- **Implementation:** Calculate album average from photo ratings, display in album grid +- **Pros:** + - Complete feature (photos + albums) + - More useful to users +- **Cons:** + - Increases scope significantly + - More complex queries (aggregate of aggregates) + - Unclear UX (what does album rating mean? average of photos? weighted by photo quality?) + +**Impact:** Low - out of current scope, but may be user expectation + +--- + +### Q001-22: Rating Export in Photo Backup + +**Context:** Lychee supports photo export/backup functionality. Spec doesn't clarify if rating data should be included in exports. + +**Question:** Should photo export/backup include rating data (user's own rating and/or aggregates)? + +**Options (ordered by preference):** + +**Option A: Include in export (CSV/JSON format) (Recommended)** +- **Export fields:** photo_id, user's rating, average rating, rating count +- **Pros:** + - Complete data portability + - Users can back up their ratings + - Useful for data analysis outside Lychee +- **Cons:** + - Larger export files + - Privacy concerns if export is shared (includes others' aggregate data) + +**Option B: Export user's ratings only (not aggregates)** +- **Export fields:** photo_id, user's rating +- **Pros:** + - User data portability + - No privacy concerns (only user's own data) +- **Cons:** + - Incomplete export (aggregates lost) + +**Option C: No export (ratings are ephemeral/server-side only)** +- **Decision:** Ratings not included in photo exports +- **Pros:** + - Simpler export logic + - Smaller export files +- **Cons:** + - Data loss risk if server fails + - No migration path to other platforms + +**Impact:** Low - affects data portability, not core functionality + +--- + +### Q001-23: Rating Notification to Photo Owner + +**Context:** When other users rate a photo, the photo owner may want to be notified (similar to comment notifications). + +**Question:** Should photo owners receive notifications when their photos are rated? + +**Options (ordered by preference):** + +**Option A: Defer to future feature (notifications system) (Recommended)** +- **Decision:** Not in scope for Feature 001, add when notifications framework is implemented +- **Pros:** + - Keeps feature scope focused + - Requires notifications infrastructure (may not exist yet) + - Can be added non-intrusively later +- **Cons:** + - Photo owners won't know when photos are rated + - Lower engagement + +**Option B: Simple email notification** +- **Implementation:** Send email to photo owner when photo is rated (with throttling: max 1 email per photo per day) +- **Pros:** + - Engagement boost + - Photo owners stay informed +- **Cons:** + - Email fatigue (could get many emails) + - Requires email configuration + - Increases scope + +**Option C: In-app notification only (no email)** +- **Implementation:** Show notification bell/count in Lychee UI when photos are rated +- **Pros:** + - Less intrusive than email + - Real-time feedback when user is active +- **Cons:** + - Requires notification UI infrastructure + - User may miss notifications if not logged in + +**Impact:** Low - nice-to-have feature, not core rating functionality + +--- + +### Q001-24: Statistics Recalculation Artisan Command + +**Context:** Implementation notes mention "artisan command to recalculate all statistics from photo_ratings table for data integrity audits." + +**Question:** Should we implement an artisan command to recalculate rating statistics, and if so, when should it be used? + +**Options (ordered by preference):** + +**Option A: Yes, implement `php artisan photos:recalculate-ratings` command (Recommended)** +- **Usage:** Run manually after data migration, database corruption, or as periodic audit +- **Behavior:** Iterate all photos, sum ratings from photo_ratings table, update photo_statistics +- **Pros:** + - Data integrity safety net + - Useful for debugging/auditing + - Can fix inconsistencies from bugs or manual DB edits +- **Cons:** + - Extra code to maintain + - May be slow on large databases + - Risk of overwriting correct data if command is buggy + +**Option B: No command, rely on transaction integrity** +- **Decision:** Trust atomic transactions to maintain consistency, no recalculation needed +- **Pros:** + - Simpler (less code) + - Transactions should guarantee consistency +- **Cons:** + - No recovery if bug causes inconsistency + - No way to audit/verify correctness + +**Option C: Automated periodic recalculation (cron job)** +- **Implementation:** Run recalculation command daily/weekly via scheduler +- **Pros:** + - Automatic data integrity maintenance + - Catches and fixes issues proactively +- **Cons:** + - Resource intensive (extra DB load) + - May mask underlying bugs instead of fixing them + - Overkill if transactions are working correctly + +**Impact:** Low - data integrity safety feature, not core functionality + +--- + +### Q001-25: Migration Strategy for Existing Installations + +**Context:** When existing Lychee installations upgrade to this feature, they'll have photos but no rating data. Migration behavior isn't specified. + +**Question:** How should the migration handle existing photos with no rating data? + +**Options (ordered by preference):** + +**Option A: Migration adds columns with defaults, no backfill (Recommended)** +- **Behavior:** Migration adds rating_sum/rating_count columns with default 0, existing photos have no ratings +- **Pros:** + - Clean state (accurate: no ratings yet) + - Fast migration (no data processing) + - No assumptions about historical data +- **Cons:** + - Existing photos start with no ratings (expected behavior) + +**Option B: Backfill with random/seeded ratings (dev/test only)** +- **Behavior:** For development, optionally seed some random ratings for testing +- **Pros:** + - Easier to test rating display with real-looking data +- **Cons:** + - Fake data, not suitable for production + - Could confuse users if accidentally run in production + +**Option C: Import from external source (if available)** +- **Behavior:** If migrating from another system with ratings, provide import script +- **Pros:** + - Preserves historical rating data +- **Cons:** + - Complex, requires external data source + - Not applicable to most installations + - Out of scope for Feature 001 + +**Impact:** Low - affects upgrade experience, but default behavior (no ratings) is expected --- @@ -27,4 +1140,4 @@ _No question details currently tracked._ --- -*Last updated: December 21, 2025* +*Last updated: 2025-12-27* diff --git a/docs/specs/4-architecture/roadmap.md b/docs/specs/4-architecture/roadmap.md index 817d55c8e4a..b8114d82ad6 100644 --- a/docs/specs/4-architecture/roadmap.md +++ b/docs/specs/4-architecture/roadmap.md @@ -6,13 +6,13 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Status | Priority | Assignee | Started | Updated | |------------|------|--------|----------|----------|---------|---------| -| _No active features currently tracked_ | | | | | | | +| _No active features_ | | | | | | | ## Completed Features | Feature ID | Name | Completed | Notes | |------------|------|-----------|-------| -| _No completed features yet_ | | | | +| 001 | Photo Star Rating | 2025-12-27 | User ratings (1-5 stars), statistics aggregation, configurable visibility | ## Backlog @@ -75,4 +75,4 @@ features/ --- -*Last updated: December 21, 2025* +*Last updated: 2025-12-27* diff --git a/lang/ar/gallery.php b/lang/ar/gallery.php index 970a637342b..72cf36970ea 100644 --- a/lang/ar/gallery.php +++ b/lang/ar/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'تعيين تاريخ الالتقاط', 'set_taken_at_info' => 'عند التعيين، سيتم عرض نجمة %s للإشارة إلى أن هذا التاريخ ليس تاريخ EXIF الأصلي.
قم بإلغاء تحديد خانة الاختيار هذه واحفظ لت reset إلى التاريخ الأصلي.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'محتوى حساس', diff --git a/lang/cz/gallery.php b/lang/cz/gallery.php index cb4b38033f7..3c2fe186f6d 100644 --- a/lang/cz/gallery.php +++ b/lang/cz/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/de/gallery.php b/lang/de/gallery.php index cc3b6dbe60e..10b3da691a7 100644 --- a/lang/de/gallery.php +++ b/lang/de/gallery.php @@ -181,6 +181,19 @@ 'set_taken_at' => 'Aufnahmedatum festlegen', 'set_taken_at_info' => 'Wenn diese Option aktiviert ist, wird ein Sternchen %s angezeigt, um darauf hinzuweisen, dass es sich bei diesem Datum nicht um das ursprüngliche EXIF-Datum handelt.
Deaktivieren des Kontrollkästchens und speichern stellt das ursprüngliche Datum wieder her.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensibler Inhalt', diff --git a/lang/el/gallery.php b/lang/el/gallery.php index 10d66ab9659..18fb9707cb5 100644 --- a/lang/el/gallery.php +++ b/lang/el/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/en/gallery.php b/lang/en/gallery.php index 6ea1712a30f..bcf04b52bda 100644 --- a/lang/en/gallery.php +++ b/lang/en/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/es/gallery.php b/lang/es/gallery.php index c079820730a..dc889df363d 100644 --- a/lang/es/gallery.php +++ b/lang/es/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Establecer fecha de toma', 'set_taken_at_info' => 'Cuando se configura, se mostrará un asterisco %s para indicar que esta fecha no es la fecha EXIF original.
Desmarque la casilla de verificación y guarde para restablecer la fecha original.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Contenido sensible', diff --git a/lang/fa/gallery.php b/lang/fa/gallery.php index 954b9a3184d..cd83fcf49ac 100644 --- a/lang/fa/gallery.php +++ b/lang/fa/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'تنظیم تاریخ ثبت', 'set_taken_at_info' => 'در صورت تنظیم، یک ستاره %s نمایش داده می‌شود تا نشان دهد این تاریخ، تاریخ اصلی EXIF نیست.
برای بازنشانی به تاریخ اصلی، تیک را بردارید و ذخیره کنید.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'محتوای حساس', diff --git a/lang/fr/gallery.php b/lang/fr/gallery.php index 41b31d7a204..09a831c4917 100644 --- a/lang/fr/gallery.php +++ b/lang/fr/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Définir la date de prise de vue', 'set_taken_at_info' => 'Une étoile %s sera affichée si cette date n’est pas la date EXIF d’origine.
Décochez la case et enregistrez pour revenir à la date d’origine.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Contenu sensible', diff --git a/lang/hu/gallery.php b/lang/hu/gallery.php index 7915092b103..98d1c8659ec 100644 --- a/lang/hu/gallery.php +++ b/lang/hu/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/it/gallery.php b/lang/it/gallery.php index 30e32c9abd4..0583d79f99d 100644 --- a/lang/it/gallery.php +++ b/lang/it/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/ja/gallery.php b/lang/ja/gallery.php index 6300028cee5..e45483156eb 100644 --- a/lang/ja/gallery.php +++ b/lang/ja/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/nl/gallery.php b/lang/nl/gallery.php index 84c469785f2..aea58dc0f12 100644 --- a/lang/nl/gallery.php +++ b/lang/nl/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Opnamedatum instellen', 'set_taken_at_info' => 'Wanneer ingesteld, wordt een ster %s weergegeven om aan te geven dat deze datum niet de originele EXIF-datum is.
Vink het selectievakje uit en sla op om terug te keren naar de originele datum.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Gevoelige inhoud', diff --git a/lang/no/gallery.php b/lang/no/gallery.php index 1f708b76bbe..a4a91df6397 100644 --- a/lang/no/gallery.php +++ b/lang/no/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/pl/gallery.php b/lang/pl/gallery.php index 026b3533191..0126cc0e9a4 100644 --- a/lang/pl/gallery.php +++ b/lang/pl/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Ustaw datę wykonania', 'set_taken_at_info' => 'Po ustawieniu wyświetlona zostanie gwiazdka %s wskazująca, że ta data nie jest oryginalną datą EXIF.
Zaznacz pole wyboru i zapisz, aby zresetować do oryginalnej daty.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Wrażliwa zawartość', diff --git a/lang/pt/gallery.php b/lang/pt/gallery.php index 9b715b31ee0..9a3dfbe958e 100644 --- a/lang/pt/gallery.php +++ b/lang/pt/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/ru/gallery.php b/lang/ru/gallery.php index 24959fabe8d..3cc5bf07a57 100644 --- a/lang/ru/gallery.php +++ b/lang/ru/gallery.php @@ -181,6 +181,19 @@ 'set_taken_at' => 'Установить дату съемки', 'set_taken_at_info' => 'При установке будет отображаться звезда %s, чтобы указать, что эта дата отличается от оригинальной EXIF даты.
Снимите галочку и сохраните, чтобы сбросить на оригинальную дату.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Чувствительный контент', diff --git a/lang/sk/gallery.php b/lang/sk/gallery.php index ce90ebf5416..17ea8b9615d 100644 --- a/lang/sk/gallery.php +++ b/lang/sk/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/sv/gallery.php b/lang/sv/gallery.php index 1234ecb74e8..86c40b77d12 100644 --- a/lang/sv/gallery.php +++ b/lang/sv/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/vi/gallery.php b/lang/vi/gallery.php index 16595df66d5..b4d75c510c0 100644 --- a/lang/vi/gallery.php +++ b/lang/vi/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/lang/zh_CN/gallery.php b/lang/zh_CN/gallery.php index 1a96acc0990..ca10ee567ca 100644 --- a/lang/zh_CN/gallery.php +++ b/lang/zh_CN/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => '设置拍摄日期', 'set_taken_at_info' => '设置后,将显示星号 %s 表示此日期不是原始 EXIF 日期。
取消选中复选框并保存以重置为原始日期。', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => '敏感内容', diff --git a/lang/zh_TW/gallery.php b/lang/zh_TW/gallery.php index 393b77609f4..e16a75725ee 100644 --- a/lang/zh_TW/gallery.php +++ b/lang/zh_TW/gallery.php @@ -182,6 +182,19 @@ 'set_taken_at' => 'Set Taken Date', 'set_taken_at_info' => 'When set, a star %s will be displayed to indicate that this date is not the original EXIF date.
Untick the checkbox and save to reset to the original date.', ], + 'rating' => [ + 'header' => 'Rating', + 'rating' => 'rating', + 'ratings' => 'ratings', + 'your_rating' => 'Your rating', + 'saving' => 'Saving rating...', + 'removed' => 'Rating removed', + 'saved' => 'Rating saved', + 'error' => 'Failed to save rating', + 'error_unauthorized' => 'You must be logged in to rate photos', + 'error_forbidden' => 'You do not have permission to rate this photo', + 'error_not_found' => 'Photo not found', + ], ], 'nsfw' => [ 'header' => 'Sensitive content', diff --git a/resources/js/components/drawers/PhotoDetails.vue b/resources/js/components/drawers/PhotoDetails.vue index fe6397e36c8..bc58d8359da 100644 --- a/resources/js/components/drawers/PhotoDetails.vue +++ b/resources/js/components/drawers/PhotoDetails.vue @@ -3,11 +3,11 @@ id="lychee_sidebar_container" :class="{ 'h-full relative transition-all overflow-x-clip overflow-y-scroll bg-bg-800': true, - 'w-[380px]': areDetailsOpen, + 'w-95': areDetailsOpen, 'w-0 ltr:translate-x-full rtl:-translate-x-full': !areDetailsOpen, }" > - + + + + + @@ -197,6 +206,7 @@ import Card from "primevue/card"; import MapInclude from "@/components/gallery/photoModule/MapInclude.vue"; import MiniIcon from "@/components/icons/MiniIcon.vue"; import ColourSquare from "@/components/gallery/photoModule/ColourSquare.vue"; +import PhotoRatingWidget from "@/components/gallery/photoModule/PhotoRatingWidget.vue"; import { useLycheeStateStore } from "@/stores/LycheeState"; import LinksInclude from "@/components/gallery/photoModule/LinksInclude.vue"; import { storeToRefs } from "pinia"; diff --git a/resources/js/components/gallery/albumModule/AlbumPanel.vue b/resources/js/components/gallery/albumModule/AlbumPanel.vue index 8307b164de9..f5de9b7bc66 100644 --- a/resources/js/components/gallery/albumModule/AlbumPanel.vue +++ b/resources/js/components/gallery/albumModule/AlbumPanel.vue @@ -234,25 +234,54 @@ const albumPanelConfig = computed(() => ({ const photoCallbacks = { star: () => { PhotoService.star(selectedPhotosIds.value, true); + // Update the photos in the store immediately to reflect the change + selectedPhotosIds.value.forEach((photoId) => { + const photo = photosStore.photos.find((p) => p.id === photoId); + if (photo) { + photo.is_starred = true; + } + }); AlbumService.clearCache(albumStore.album?.id); - emits("refresh"); }, unstar: () => { PhotoService.star(selectedPhotosIds.value, false); + // Update the photos in the store immediately to reflect the change + selectedPhotosIds.value.forEach((photoId) => { + const photo = photosStore.photos.find((p) => p.id === photoId); + if (photo) { + photo.is_starred = false; + } + }); AlbumService.clearCache(albumStore.album?.id); - emits("refresh"); }, setAsCover: () => { if (albumStore.album === undefined) return; PhotoService.setAsCover(selectedPhoto.value!.id, albumStore.album.id); + // Update the album's cover_id immediately to reflect the change (toggle behavior) + if (albumStore.modelAlbum !== undefined) { + albumStore.modelAlbum.cover_id = albumStore.modelAlbum.cover_id === selectedPhoto.value!.id ? null : selectedPhoto.value!.id; + } AlbumService.clearCache(albumStore.album.id); - emits("refresh"); }, setAsHeader: () => { if (albumStore.album === undefined) return; PhotoService.setAsHeader(selectedPhoto.value!.id, albumStore.album.id, false); + // Update the album's header_id immediately to reflect the change (toggle behavior) + const isToggleOff = albumStore.modelAlbum?.header_id === selectedPhoto.value!.id; + if (albumStore.modelAlbum !== undefined) { + albumStore.modelAlbum.header_id = isToggleOff ? null : selectedPhoto.value!.id; + } + // Update the header image URL in the album's preFormattedData + if (albumStore.album.preFormattedData) { + if (isToggleOff) { + albumStore.album.preFormattedData.url = null; + } else { + // Use medium or small variant for the header image + const headerUrl = selectedPhoto.value!.size_variants.medium?.url ?? selectedPhoto.value!.size_variants.small?.url ?? null; + albumStore.album.preFormattedData.url = headerUrl; + } + } AlbumService.clearCache(albumStore.album.id); - emits("refresh"); }, toggleTag: toggleTag, toggleRename: toggleRename, @@ -283,6 +312,11 @@ const albumCallbacks = { if (albumStore.album === undefined) return; if (selectedAlbum.value?.thumb?.id === undefined) return; PhotoService.setAsCover(selectedAlbum.value!.thumb?.id, albumStore.album.id); + // Update the album's cover_id immediately to reflect the change (toggle behavior) + if (albumStore.modelAlbum !== undefined) { + albumStore.modelAlbum.cover_id = + albumStore.modelAlbum.cover_id === selectedAlbum.value!.thumb?.id ? null : selectedAlbum.value!.thumb?.id; + } AlbumService.clearCache(albumStore.album.id); emits("refresh"); }, diff --git a/resources/js/components/gallery/albumModule/thumbs/AlbumThumb.vue b/resources/js/components/gallery/albumModule/thumbs/AlbumThumb.vue index 15959f4bcc9..68ae1cfbbe7 100644 --- a/resources/js/components/gallery/albumModule/thumbs/AlbumThumb.vue +++ b/resources/js/components/gallery/albumModule/thumbs/AlbumThumb.vue @@ -75,7 +75,7 @@ import { useAlbumsStore } from "@/stores/AlbumsState"; export type AlbumThumbConfig = { album_thumb_css_aspect_ratio: string; album_subtitle_type: App.Enum.ThumbAlbumSubtitleType; - display_thumb_album_overlay: App.Enum.ThumbOverlayVisibilityType; + display_thumb_album_overlay: App.Enum.VisibilityType; album_decoration: App.Enum.AlbumDecorationType; album_decoration_orientation: App.Enum.AlbumDecorationOrientation; }; diff --git a/resources/js/components/gallery/albumModule/thumbs/PhotoThumb.vue b/resources/js/components/gallery/albumModule/thumbs/PhotoThumb.vue index f0f045acb04..b69207de760 100644 --- a/resources/js/components/gallery/albumModule/thumbs/PhotoThumb.vue +++ b/resources/js/components/gallery/albumModule/thumbs/PhotoThumb.vue @@ -45,9 +45,7 @@ }" > @@ -90,12 +88,15 @@