Aggiunge gestione archivio replay nel pannello admin.
Gli admin possono vedere, modificare ed eliminare i replay di una società come la squadra proprietaria, riusando la stessa UI e logica dell'archivio pubblico. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,8 @@ module Admin
|
|||||||
before_action :require_admin_login
|
before_action :require_admin_login
|
||||||
|
|
||||||
include ::AdminHelper
|
include ::AdminHelper
|
||||||
|
helper ApplicationHelper
|
||||||
|
helper RecordingsArchiveHelper
|
||||||
helper_method :current_admin_account, :admin_logged_in?
|
helper_method :current_admin_account, :admin_logged_in?
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
15
backend/app/controllers/admin/club_recordings_controller.rb
Normal file
15
backend/app/controllers/admin/club_recordings_controller.rb
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
module Admin
|
||||||
|
class ClubRecordingsController < BaseController
|
||||||
|
include Recordings::ClubArchiveActions
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def archive_namespace
|
||||||
|
:admin
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_recording_management!
|
||||||
|
# Admin può gestire tutti i replay della società senza vincoli di ruolo utente.
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
module Recordings
|
||||||
|
module ClubArchiveActions
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
include RecordingsArchiveHelper
|
||||||
|
|
||||||
|
included do
|
||||||
|
before_action :set_club
|
||||||
|
before_action :set_recording, only: %i[update destroy publish_youtube]
|
||||||
|
end
|
||||||
|
|
||||||
|
def index
|
||||||
|
load_archive
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
permitted = params.require(:recording).permit(:privacy_status, :title)
|
||||||
|
attrs = {}
|
||||||
|
privacy = permitted[:privacy_status]
|
||||||
|
attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES)
|
||||||
|
title = permitted[:title]
|
||||||
|
attrs[:title] = title if title.present?
|
||||||
|
privacy_changed = attrs.key?(:privacy_status) && attrs[:privacy_status] != @recording.privacy_status
|
||||||
|
@recording.update!(attrs)
|
||||||
|
Recordings::SyncYoutubePrivacyJob.perform_async(@recording.id) if privacy_changed && @recording.youtube_video_id.present?
|
||||||
|
redirect_to archive_index_path, notice: "Replay aggiornato"
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
redirect_to archive_index_path, alert: e.record.errors.full_messages.join(", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
Recordings::Delete.new(@recording).call
|
||||||
|
redirect_to archive_index_path, notice: "Replay eliminato"
|
||||||
|
end
|
||||||
|
|
||||||
|
def publish_youtube
|
||||||
|
ent = @recording.team.entitlements
|
||||||
|
unless ent.premium_full? && ent.youtube_enabled?
|
||||||
|
redirect_to archive_index_path, alert: "Republicazione YouTube con Premium Full"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
Recordings::PublishToYoutubeJob.perform_async(@recording.id)
|
||||||
|
redirect_to archive_index_path, notice: "Pubblicazione su YouTube avviata"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_archive
|
||||||
|
archive = Recordings::ClubArchive.new(@club, params: params)
|
||||||
|
@stats = archive.stats
|
||||||
|
@recordings = archive.recordings
|
||||||
|
@teams = archive.teams
|
||||||
|
@filter_team_id = archive.filter_team_id
|
||||||
|
@filter_status = archive.filter_status
|
||||||
|
@archive_paths = recordings_archive_paths(@club, namespace: archive_namespace)
|
||||||
|
@archive_filter_params = archive.filter_params
|
||||||
|
end
|
||||||
|
|
||||||
|
def archive_index_path
|
||||||
|
archive_paths.index + archive_query_string
|
||||||
|
end
|
||||||
|
|
||||||
|
def archive_query_string
|
||||||
|
q = @archive_filter_params.presence
|
||||||
|
return "" if q.blank?
|
||||||
|
|
||||||
|
"?#{q.to_query}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def archive_paths
|
||||||
|
@archive_paths ||= recordings_archive_paths(@club, namespace: archive_namespace)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_club
|
||||||
|
@club = Club.find(params[:club_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_recording
|
||||||
|
@recording = Recording.for_club(@club).not_deleted.find(params[:id])
|
||||||
|
authorize_recording_management!
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_recording_management!
|
||||||
|
# overridden in Public::ClubRecordingsController
|
||||||
|
end
|
||||||
|
|
||||||
|
def archive_namespace
|
||||||
|
raise NotImplementedError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,73 +1,21 @@
|
|||||||
module Public
|
module Public
|
||||||
class ClubRecordingsController < WebBaseController
|
class ClubRecordingsController < WebBaseController
|
||||||
|
include Recordings::ClubArchiveActions
|
||||||
|
helper RecordingsArchiveHelper
|
||||||
|
|
||||||
before_action :require_login!
|
before_action :require_login!
|
||||||
before_action :set_club
|
|
||||||
before_action :require_club_recording_manager!
|
before_action :require_club_recording_manager!
|
||||||
before_action :set_recording, only: %i[update destroy publish_youtube]
|
|
||||||
|
|
||||||
def index
|
|
||||||
@stats = Recordings::ClubStats.new(@club).call
|
|
||||||
@recordings = Recording.for_club(@club)
|
|
||||||
.not_deleted
|
|
||||||
.includes(stream_session: { match: :team })
|
|
||||||
.order(recorded_at: :desc, created_at: :desc)
|
|
||||||
if params[:team_id].present?
|
|
||||||
@recordings = @recordings.where(team_id: params[:team_id])
|
|
||||||
end
|
|
||||||
if params[:status].present?
|
|
||||||
case params[:status]
|
|
||||||
when "ready"
|
|
||||||
@recordings = @recordings.ready
|
|
||||||
when "processing"
|
|
||||||
@recordings = @recordings.where(status: "processing")
|
|
||||||
when "expired"
|
|
||||||
@recordings = @recordings.where(
|
|
||||||
"recordings.status IN (?) OR (recordings.expires_at IS NOT NULL AND recordings.expires_at <= ?)",
|
|
||||||
%w[expired failed], Time.current
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@teams = @club.teams.order(:name)
|
|
||||||
@filter_team_id = params[:team_id]
|
|
||||||
@filter_status = params[:status]
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
permitted = params.require(:recording).permit(:privacy_status, :title)
|
|
||||||
attrs = {}
|
|
||||||
privacy = permitted[:privacy_status]
|
|
||||||
attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES)
|
|
||||||
title = permitted[:title]
|
|
||||||
attrs[:title] = title if title.present?
|
|
||||||
privacy_changed = attrs.key?(:privacy_status) && attrs[:privacy_status] != @recording.privacy_status
|
|
||||||
@recording.update!(attrs)
|
|
||||||
Recordings::SyncYoutubePrivacyJob.perform_async(@recording.id) if privacy_changed && @recording.youtube_video_id.present?
|
|
||||||
redirect_to public_club_recordings_path(@club, team_id: params[:team_id], status: params[:status]),
|
|
||||||
notice: "Replay aggiornato"
|
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
|
||||||
redirect_to public_club_recordings_path(@club), alert: e.record.errors.full_messages.join(", ")
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy
|
|
||||||
Recordings::Delete.new(@recording).call
|
|
||||||
redirect_to public_club_recordings_path(@club), notice: "Replay eliminato"
|
|
||||||
end
|
|
||||||
|
|
||||||
def publish_youtube
|
|
||||||
ent = @recording.team.entitlements
|
|
||||||
unless ent.premium_full? && ent.youtube_enabled?
|
|
||||||
redirect_to public_club_recordings_path(@club), alert: "Republicazione YouTube con Premium Full"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
Recordings::PublishToYoutubeJob.perform_async(@recording.id)
|
|
||||||
redirect_to public_club_recordings_path(@club), notice: "Pubblicazione su YouTube avviata"
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_club
|
def archive_namespace
|
||||||
@club = Club.find(params[:club_id])
|
:public
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_recording_management!
|
||||||
|
return if Recordings::AccessPolicy.new(@recording, viewer: current_user).manage_allowed?
|
||||||
|
|
||||||
|
raise ActiveRecord::RecordNotFound
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_club_recording_manager!
|
def require_club_recording_manager!
|
||||||
@@ -76,12 +24,5 @@ module Public
|
|||||||
|
|
||||||
redirect_to public_clubs_path, alert: "Non autorizzato"
|
redirect_to public_clubs_path, alert: "Non autorizzato"
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_recording
|
|
||||||
@recording = Recording.for_club(@club).not_deleted.find(params[:id])
|
|
||||||
return if Recordings::AccessPolicy.new(@recording, viewer: current_user).manage_allowed?
|
|
||||||
|
|
||||||
raise ActiveRecord::RecordNotFound
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
24
backend/app/helpers/recordings_archive_helper.rb
Normal file
24
backend/app/helpers/recordings_archive_helper.rb
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module RecordingsArchiveHelper
|
||||||
|
ArchivePaths = Struct.new(:index, :update, :destroy, :publish_youtube, :back, keyword_init: true)
|
||||||
|
|
||||||
|
def recordings_archive_paths(club, namespace:)
|
||||||
|
case namespace
|
||||||
|
when :admin
|
||||||
|
ArchivePaths.new(
|
||||||
|
index: admin_club_recordings_path(club),
|
||||||
|
update: ->(rec) { admin_club_recording_path(club, rec) },
|
||||||
|
destroy: ->(rec) { admin_club_recording_path(club, rec) },
|
||||||
|
publish_youtube: ->(rec) { publish_youtube_admin_club_recording_path(club, rec) },
|
||||||
|
back: admin_club_path(club)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ArchivePaths.new(
|
||||||
|
index: public_club_recordings_path(club),
|
||||||
|
update: ->(rec) { public_club_recording_path(club, rec) },
|
||||||
|
destroy: ->(rec) { public_club_recording_path(club, rec) },
|
||||||
|
publish_youtube: ->(rec) { public_club_recording_publish_youtube_path(club, rec) },
|
||||||
|
back: public_club_path(club)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
55
backend/app/services/recordings/club_archive.rb
Normal file
55
backend/app/services/recordings/club_archive.rb
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
module Recordings
|
||||||
|
class ClubArchive
|
||||||
|
def initialize(club, params: {})
|
||||||
|
@club = club
|
||||||
|
@params = params
|
||||||
|
end
|
||||||
|
|
||||||
|
def stats
|
||||||
|
Recordings::ClubStats.new(@club).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def teams
|
||||||
|
@club.teams.order(:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_team_id
|
||||||
|
@params[:team_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_status
|
||||||
|
@params[:status]
|
||||||
|
end
|
||||||
|
|
||||||
|
def recordings
|
||||||
|
scope = Recording.for_club(@club)
|
||||||
|
.not_deleted
|
||||||
|
.includes(stream_session: { match: :team })
|
||||||
|
.order(recorded_at: :desc, created_at: :desc)
|
||||||
|
scope = scope.where(team_id: filter_team_id) if filter_team_id.present?
|
||||||
|
apply_status_filter(scope)
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_params
|
||||||
|
{ team_id: filter_team_id, status: filter_status }.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def apply_status_filter(scope)
|
||||||
|
case filter_status
|
||||||
|
when "ready"
|
||||||
|
scope.ready
|
||||||
|
when "processing"
|
||||||
|
scope.where(status: "processing")
|
||||||
|
when "expired"
|
||||||
|
scope.where(
|
||||||
|
"recordings.status IN (?) OR (recordings.expires_at IS NOT NULL AND recordings.expires_at <= ?)",
|
||||||
|
%w[expired failed], Time.current
|
||||||
|
)
|
||||||
|
else
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
4
backend/app/views/admin/club_recordings/index.html.erb
Normal file
4
backend/app/views/admin/club_recordings/index.html.erb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<% content_for :title, "Replay — #{@club.name}" %>
|
||||||
|
<% content_for :replay_archive_styles, true %>
|
||||||
|
|
||||||
|
<%= render "recordings/club_archive", admin_mode: true %>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<h2><%= @club.name %></h2>
|
<h2><%= @club.name %></h2>
|
||||||
<p style="color:#888">
|
<p style="color:#888">
|
||||||
Sport: <%= @club.sport %>
|
Sport: <%= @club.sport %>
|
||||||
|
· <%= link_to "Archivio replay", admin_club_recordings_path(@club) %>
|
||||||
· <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @club.id) %>
|
· <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @club.id) %>
|
||||||
· <%= link_to "Canale YouTube piattaforma", admin_youtube_platform_path %>
|
· <%= link_to "Canale YouTube piattaforma", admin_youtube_platform_path %>
|
||||||
</p>
|
</p>
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><strong><%= team.name %></strong></td>
|
<td><strong><%= team.name %></strong></td>
|
||||||
<td><%= team.sport %></td>
|
<td><%= team.sport %></td>
|
||||||
<td><%= link_to "Partite e dettagli", admin_team_path(team) %></td>
|
<td><%= link_to "Partite e dettagli", admin_team_path(team) %> · <%= link_to "Replay", admin_club_recordings_path(@club, team_id: team.id) %></td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<h2><%= @team.name %></h2>
|
<h2><%= @team.name %></h2>
|
||||||
<p>Sport: <%= @team.sport %></p>
|
<p>Sport: <%= @team.sport %></p>
|
||||||
<% if @team.club %>
|
<% if @team.club %>
|
||||||
<p>Società: <%= @team.club.name %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @team.club.id) %></p>
|
<p>Società: <%= @team.club.name %> · <%= link_to "Replay squadra", admin_club_recordings_path(@team.club, team_id: @team.id) %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @team.club.id) %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% yt = Youtube::TeamStatus.new(@team) %>
|
<% yt = Youtube::TeamStatus.new(@team) %>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<link rel="stylesheet" href="/admin.css?v=2">
|
<link rel="stylesheet" href="/admin.css?v=2">
|
||||||
|
<% if content_for?(:replay_archive_styles) %>
|
||||||
|
<link rel="stylesheet" href="/marketing.css?v=1">
|
||||||
|
<% end %>
|
||||||
<% if controller_name == "dashboard" %>
|
<% if controller_name == "dashboard" %>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
|
||||||
<script src="/admin-dashboard.js?v=1" defer></script>
|
<script src="/admin-dashboard.js?v=1" defer></script>
|
||||||
@@ -20,7 +23,7 @@
|
|||||||
<%= link_to admin_ops_path, class: ("active" if controller_name == "ops") do %>
|
<%= link_to admin_ops_path, class: ("active" if controller_name == "ops") do %>
|
||||||
Ops<% if ops_critical.positive? %> <span class="admin-nav-badge"><%= ops_critical %></span><% end %>
|
Ops<% if ops_critical.positive? %> <span class="admin-nav-badge"><%= ops_critical %></span><% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= link_to "Società e squadre", admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams])) %>
|
<%= link_to "Società e squadre", admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams club_recordings])) %>
|
||||||
<%= link_to "Fatturazione", admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
<%= link_to "Fatturazione", admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
||||||
<%= link_to "YouTube", admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
<%= link_to "YouTube", admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
||||||
<%= link_to "Sessions", admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
<%= link_to "Sessions", admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
||||||
|
|||||||
@@ -1,150 +1,6 @@
|
|||||||
<% content_for :title, "Replay — #{@club.name}" %>
|
<% content_for :title, "Replay — #{@club.name}" %>
|
||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
<div class="wrap club-dashboard replay-archive" style="padding-top:20px">
|
<div class="wrap club-dashboard" style="padding-top:20px">
|
||||||
<%= link_to "← #{@club.name}", public_club_path(@club), class: "back-link" %>
|
<%= render "recordings/club_archive", admin_mode: false %>
|
||||||
|
|
||||||
<h1>Archivio Replay</h1>
|
|
||||||
<p class="muted">
|
|
||||||
Gestisci registrazioni, visibilità e download.
|
|
||||||
<strong>Privato</strong> = link non indicizzato, non compare in <code>/replay</code> pubblico.
|
|
||||||
Eliminando un replay vengono rimossi anche i file sul server e il video YouTube collegato.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="kpi-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin:20px 0">
|
|
||||||
<div class="card" style="padding:16px">
|
|
||||||
<div class="kpi-value"><%= @stats[:available_count] %></div>
|
|
||||||
<div class="kpi-sub">Replay disponibili</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="padding:16px">
|
|
||||||
<div class="kpi-value"><%= @stats[:expiring_soon_count] %></div>
|
|
||||||
<div class="kpi-sub">In scadenza (7 gg)</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="padding:16px">
|
|
||||||
<div class="kpi-value"><%= number_to_human_size(@stats[:total_bytes]) %></div>
|
|
||||||
<div class="kpi-sub">Spazio archivio</div>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="padding:16px">
|
|
||||||
<div class="kpi-value"><%= @stats[:total_views] %></div>
|
|
||||||
<div class="kpi-sub">Visualizzazioni totali</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= form_with url: public_club_recordings_path(@club), method: :get, local: true, class: "replay-filter-form" do %>
|
|
||||||
<label for="team_id" class="muted">Squadra</label>
|
|
||||||
<select name="team_id" id="team_id" class="input" onchange="this.form.submit()">
|
|
||||||
<option value="">Tutte</option>
|
|
||||||
<% @teams.each do |team| %>
|
|
||||||
<option value="<%= team.id %>"<%= " selected" if @filter_team_id.to_s == team.id.to_s %>><%= team.name %></option>
|
|
||||||
<% end %>
|
|
||||||
</select>
|
|
||||||
<label for="status" class="muted">Stato</label>
|
|
||||||
<select name="status" id="status" class="input" onchange="this.form.submit()">
|
|
||||||
<option value="">Tutti</option>
|
|
||||||
<option value="ready"<%= " selected" if @filter_status == "ready" %>>Disponibili</option>
|
|
||||||
<option value="processing"<%= " selected" if @filter_status == "processing" %>>In elaborazione</option>
|
|
||||||
<option value="expired"<%= " selected" if @filter_status == "expired" %>>Scaduti / errore</option>
|
|
||||||
</select>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if @recordings.any? %>
|
|
||||||
<div class="card replay-archive__card">
|
|
||||||
<table class="data replay-archive-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="replay-archive-table__col-thumb" scope="col"><span class="visually-hidden">Anteprima</span></th>
|
|
||||||
<th scope="col">Partita</th>
|
|
||||||
<th scope="col">Dettagli</th>
|
|
||||||
<th scope="col">Stato e visibilità</th>
|
|
||||||
<th class="replay-archive-table__col-actions" scope="col">Azioni</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% @recordings.each do |rec| %>
|
|
||||||
<% ent = rec.team.entitlements %>
|
|
||||||
<% can_play = rec.ready? && rec.replay_url.present? %>
|
|
||||||
<tr>
|
|
||||||
<td class="replay-archive-table__col-thumb">
|
|
||||||
<% if can_play %>
|
|
||||||
<%= link_to rec.replay_url, class: "replay-archive__thumb", target: "_blank", rel: "noopener", title: "Guarda replay" do %>
|
|
||||||
<% if rec.thumbnail_url %>
|
|
||||||
<img src="<%= rec.thumbnail_url %>" alt="" class="replay-archive__thumb-img" width="80" height="45" loading="lazy">
|
|
||||||
<% else %>
|
|
||||||
<span class="replay-archive__thumb-placeholder" aria-hidden="true">▶</span>
|
|
||||||
<% end %>
|
|
||||||
<span class="replay-archive__play-btn" aria-hidden="true">▶</span>
|
|
||||||
<% end %>
|
|
||||||
<% else %>
|
|
||||||
<div class="replay-archive__thumb replay-archive__thumb--static" title="<%= rec.status == "processing" ? "Elaborazione in corso" : "Replay non disponibile" %>">
|
|
||||||
<% if rec.thumbnail_url %>
|
|
||||||
<img src="<%= rec.thumbnail_url %>" alt="" class="replay-archive__thumb-img" width="80" height="45" loading="lazy">
|
|
||||||
<% else %>
|
|
||||||
<span class="replay-archive__thumb-placeholder" aria-hidden="true"><%= rec.status == "processing" ? "…" : "—" %></span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td class="replay-archive-table__col-match">
|
|
||||||
<%= form_with url: public_club_recording_path(@club, rec), method: :patch, local: true, class: "replay-archive__title-form" do %>
|
|
||||||
<%= hidden_field_tag :team_id, @filter_team_id if @filter_team_id.present? %>
|
|
||||||
<%= hidden_field_tag :status, @filter_status if @filter_status.present? %>
|
|
||||||
<%= text_field_tag "recording[title]", rec.title_or_default, class: "replay-archive__title-input", onchange: "this.form.submit()" %>
|
|
||||||
<% end %>
|
|
||||||
<span class="replay-archive__meta-line"><%= rec.source_platform_label %></span>
|
|
||||||
</td>
|
|
||||||
<td class="replay-archive-table__col-details">
|
|
||||||
<span class="replay-archive__detail-line">
|
|
||||||
<%= l_local(rec.recorded_at_or_fallback, format: :short) %>
|
|
||||||
· <%= rec.duration_label %>
|
|
||||||
· <%= rec.view_count %> vis.
|
|
||||||
</span>
|
|
||||||
<span class="replay-archive__meta-line">
|
|
||||||
<% if rec.expires_at %>
|
|
||||||
Scade <%= l_local(rec.expires_at, format: :short) %>
|
|
||||||
<% if rec.days_until_expiry && rec.days_until_expiry.positive? %>
|
|
||||||
(<%= rec.days_until_expiry %> gg)
|
|
||||||
<% end %>
|
|
||||||
<% else %>
|
|
||||||
Nessuna scadenza
|
|
||||||
<% end %>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="replay-archive-table__col-status">
|
|
||||||
<span class="replay-archive__status replay-archive__status--<%= rec.status %>"><%= rec.status_label %></span>
|
|
||||||
<%= form_with url: public_club_recording_path(@club, rec), method: :patch, local: true, class: "replay-archive__privacy-form" do %>
|
|
||||||
<%= hidden_field_tag :team_id, @filter_team_id if @filter_team_id.present? %>
|
|
||||||
<%= hidden_field_tag :status, @filter_status if @filter_status.present? %>
|
|
||||||
<%= select_tag "recording[privacy_status]",
|
|
||||||
options_for_select([["Pubblico", "public"], ["Privato", "unlisted"]], rec.privacy_status),
|
|
||||||
class: "replay-archive__privacy-select",
|
|
||||||
onchange: "this.form.submit()" %>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td class="replay-archive-table__col-actions">
|
|
||||||
<div class="replay-archive__actions">
|
|
||||||
<% if ent.phone_download_enabled? && rec.ready? %>
|
|
||||||
<%= link_to "MP4", public_replay_download_path(rec.stream_session_id), class: "replay-archive__action replay-archive__action--secondary", title: "Scarica MP4" %>
|
|
||||||
<% end %>
|
|
||||||
<% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.youtube_video_id.blank? %>
|
|
||||||
<%= button_to "YT", public_club_recording_publish_youtube_path(@club, rec), method: :post, class: "replay-archive__action replay-archive__action--secondary", title: "Pubblica su YouTube" %>
|
|
||||||
<% elsif rec.youtube_watch_url %>
|
|
||||||
<%= link_to "YT", rec.youtube_watch_url, class: "replay-archive__action replay-archive__action--secondary", target: "_blank", rel: "noopener", title: "Apri su YouTube" %>
|
|
||||||
<% end %>
|
|
||||||
<%= button_to public_club_recording_path(@club, rec), method: :delete,
|
|
||||||
params: { team_id: @filter_team_id, status: @filter_status }.compact,
|
|
||||||
class: "replay-archive__action replay-archive__action--danger",
|
|
||||||
title: "Elimina replay",
|
|
||||||
form: { data: { turbo_confirm: "Eliminare definitivamente questo replay? Verranno rimossi i file sul server e il video YouTube collegato." }, class: "replay-archive__action-form" } do %>
|
|
||||||
✕
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<p class="muted">Nessuna registrazione ancora. I replay compaiono al termine delle dirette Premium.</p>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
153
backend/app/views/recordings/_club_archive.html.erb
Normal file
153
backend/app/views/recordings/_club_archive.html.erb
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<%# locals: admin_mode: false %>
|
||||||
|
<% paths = @archive_paths %>
|
||||||
|
<% filter_params = @archive_filter_params %>
|
||||||
|
|
||||||
|
<div class="replay-archive<%= " replay-archive--admin" if local_assigns[:admin_mode] %>">
|
||||||
|
<p style="margin-bottom:16px"><%= link_to "← #{@club.name}", paths.back %></p>
|
||||||
|
|
||||||
|
<h1>Archivio Replay<% if local_assigns[:admin_mode] %> <span class="muted" style="font-size:0.85rem;font-weight:400">(admin)</span><% end %></h1>
|
||||||
|
<p class="muted">
|
||||||
|
Gestisci registrazioni, visibilità e download.
|
||||||
|
<strong>Privato</strong> = link non indicizzato, non compare in <code>/replay</code> pubblico.
|
||||||
|
Eliminando un replay vengono rimossi anche i file sul server e il video YouTube collegato.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="kpi-grid replay-archive__kpis">
|
||||||
|
<div class="card replay-archive__kpi">
|
||||||
|
<div class="kpi-value"><%= @stats[:available_count] %></div>
|
||||||
|
<div class="kpi-sub">Replay disponibili</div>
|
||||||
|
</div>
|
||||||
|
<div class="card replay-archive__kpi">
|
||||||
|
<div class="kpi-value"><%= @stats[:expiring_soon_count] %></div>
|
||||||
|
<div class="kpi-sub">In scadenza (7 gg)</div>
|
||||||
|
</div>
|
||||||
|
<div class="card replay-archive__kpi">
|
||||||
|
<div class="kpi-value"><%= number_to_human_size(@stats[:total_bytes]) %></div>
|
||||||
|
<div class="kpi-sub">Spazio archivio</div>
|
||||||
|
</div>
|
||||||
|
<div class="card replay-archive__kpi">
|
||||||
|
<div class="kpi-value"><%= @stats[:total_views] %></div>
|
||||||
|
<div class="kpi-sub">Visualizzazioni totali</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form_with url: paths.index, method: :get, local: true, class: "replay-filter-form" do %>
|
||||||
|
<label for="team_id" class="muted">Squadra</label>
|
||||||
|
<select name="team_id" id="team_id" class="input" onchange="this.form.submit()">
|
||||||
|
<option value="">Tutte</option>
|
||||||
|
<% @teams.each do |team| %>
|
||||||
|
<option value="<%= team.id %>"<%= " selected" if @filter_team_id.to_s == team.id.to_s %>><%= team.name %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<label for="status" class="muted">Stato</label>
|
||||||
|
<select name="status" id="status" class="input" onchange="this.form.submit()">
|
||||||
|
<option value="">Tutti</option>
|
||||||
|
<option value="ready"<%= " selected" if @filter_status == "ready" %>>Disponibili</option>
|
||||||
|
<option value="processing"<%= " selected" if @filter_status == "processing" %>>In elaborazione</option>
|
||||||
|
<option value="expired"<%= " selected" if @filter_status == "expired" %>>Scaduti / errore</option>
|
||||||
|
</select>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @recordings.any? %>
|
||||||
|
<div class="card replay-archive__card">
|
||||||
|
<table class="data replay-archive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="replay-archive-table__col-thumb" scope="col"><span class="visually-hidden">Anteprima</span></th>
|
||||||
|
<th scope="col">Partita</th>
|
||||||
|
<th scope="col">Squadra</th>
|
||||||
|
<th scope="col">Dettagli</th>
|
||||||
|
<th scope="col">Stato e visibilità</th>
|
||||||
|
<th class="replay-archive-table__col-actions" scope="col">Azioni</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @recordings.each do |rec| %>
|
||||||
|
<% ent = rec.team.entitlements %>
|
||||||
|
<% can_play = rec.ready? && rec.replay_url.present? %>
|
||||||
|
<tr>
|
||||||
|
<td class="replay-archive-table__col-thumb">
|
||||||
|
<% if can_play %>
|
||||||
|
<%= link_to rec.replay_url, class: "replay-archive__thumb", target: "_blank", rel: "noopener", title: "Guarda replay" do %>
|
||||||
|
<% if rec.thumbnail_url %>
|
||||||
|
<img src="<%= rec.thumbnail_url %>" alt="" class="replay-archive__thumb-img" width="80" height="45" loading="lazy">
|
||||||
|
<% else %>
|
||||||
|
<span class="replay-archive__thumb-placeholder" aria-hidden="true">▶</span>
|
||||||
|
<% end %>
|
||||||
|
<span class="replay-archive__play-btn" aria-hidden="true">▶</span>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<div class="replay-archive__thumb replay-archive__thumb--static" title="<%= rec.status == "processing" ? "Elaborazione in corso" : "Replay non disponibile" %>">
|
||||||
|
<% if rec.thumbnail_url %>
|
||||||
|
<img src="<%= rec.thumbnail_url %>" alt="" class="replay-archive__thumb-img" width="80" height="45" loading="lazy">
|
||||||
|
<% else %>
|
||||||
|
<span class="replay-archive__thumb-placeholder" aria-hidden="true"><%= rec.status == "processing" ? "…" : "—" %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="replay-archive-table__col-match">
|
||||||
|
<%= form_with url: paths.update.call(rec), method: :patch, local: true, class: "replay-archive__title-form" do %>
|
||||||
|
<% filter_params.each { |key, value| concat hidden_field_tag(key, value) } %>
|
||||||
|
<%= text_field_tag "recording[title]", rec.title_or_default, class: "replay-archive__title-input", onchange: "this.form.submit()" %>
|
||||||
|
<% end %>
|
||||||
|
<span class="replay-archive__meta-line"><%= rec.source_platform_label %></span>
|
||||||
|
</td>
|
||||||
|
<td class="replay-archive-table__col-team">
|
||||||
|
<span class="replay-archive__meta-line"><%= rec.team.name %></span>
|
||||||
|
</td>
|
||||||
|
<td class="replay-archive-table__col-details">
|
||||||
|
<span class="replay-archive__detail-line">
|
||||||
|
<%= l_local(rec.recorded_at_or_fallback, format: :short) %>
|
||||||
|
· <%= rec.duration_label %>
|
||||||
|
· <%= rec.view_count %> vis.
|
||||||
|
</span>
|
||||||
|
<span class="replay-archive__meta-line">
|
||||||
|
<% if rec.expires_at %>
|
||||||
|
Scade <%= l_local(rec.expires_at, format: :short) %>
|
||||||
|
<% if rec.days_until_expiry && rec.days_until_expiry.positive? %>
|
||||||
|
(<%= rec.days_until_expiry %> gg)
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
Nessuna scadenza
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="replay-archive-table__col-status">
|
||||||
|
<span class="replay-archive__status replay-archive__status--<%= rec.status %>"><%= rec.status_label %></span>
|
||||||
|
<%= form_with url: paths.update.call(rec), method: :patch, local: true, class: "replay-archive__privacy-form" do %>
|
||||||
|
<% filter_params.each { |key, value| concat hidden_field_tag(key, value) } %>
|
||||||
|
<%= select_tag "recording[privacy_status]",
|
||||||
|
options_for_select([["Pubblico", "public"], ["Privato", "unlisted"]], rec.privacy_status),
|
||||||
|
class: "replay-archive__privacy-select",
|
||||||
|
onchange: "this.form.submit()" %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="replay-archive-table__col-actions">
|
||||||
|
<div class="replay-archive__actions">
|
||||||
|
<% if ent.phone_download_enabled? && rec.ready? %>
|
||||||
|
<%= link_to "MP4", public_replay_download_path(rec.stream_session_id), class: "replay-archive__action replay-archive__action--secondary", title: "Scarica MP4" %>
|
||||||
|
<% end %>
|
||||||
|
<% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.youtube_video_id.blank? %>
|
||||||
|
<%= button_to "YT", paths.publish_youtube.call(rec), method: :post, class: "replay-archive__action replay-archive__action--secondary", title: "Pubblica su YouTube" %>
|
||||||
|
<% elsif rec.youtube_watch_url %>
|
||||||
|
<%= link_to "YT", rec.youtube_watch_url, class: "replay-archive__action replay-archive__action--secondary", target: "_blank", rel: "noopener", title: "Apri su YouTube" %>
|
||||||
|
<% end %>
|
||||||
|
<%= button_to paths.destroy.call(rec), method: :delete,
|
||||||
|
params: filter_params,
|
||||||
|
class: "replay-archive__action replay-archive__action--danger",
|
||||||
|
title: "Elimina replay",
|
||||||
|
form: { data: { turbo_confirm: "Eliminare definitivamente questo replay? Verranno rimossi i file sul server e il video YouTube collegato." }, class: "replay-archive__action-form" } do %>
|
||||||
|
✕
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="muted">Nessuna registrazione per i filtri selezionati.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -93,6 +93,11 @@ Rails.application.routes.draw do
|
|||||||
delete :revoke_comped
|
delete :revoke_comped
|
||||||
end
|
end
|
||||||
resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices"
|
resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices"
|
||||||
|
resources :club_recordings, only: %i[index update destroy], path: "replays", as: :recordings do
|
||||||
|
member do
|
||||||
|
post :publish_youtube
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
resources :sessions, only: %i[index show] do
|
resources :sessions, only: %i[index show] do
|
||||||
member do
|
member do
|
||||||
|
|||||||
50
backend/spec/requests/admin/club_recordings_spec.rb
Normal file
50
backend/spec/requests/admin/club_recordings_spec.rb
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Admin club replays", type: :request do
|
||||||
|
let!(:admin) { AdminAccount.create!(username: "ops", password: "secret123") }
|
||||||
|
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:team) { club.teams.create!(name: "Tigers", sport: "volleyball") }
|
||||||
|
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.ago) }
|
||||||
|
let!(:session) do
|
||||||
|
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago)
|
||||||
|
end
|
||||||
|
let!(:recording) do
|
||||||
|
Recording.create!(
|
||||||
|
stream_session: session, team: team, status: "ready", privacy_status: "public",
|
||||||
|
title: "Finale",
|
||||||
|
storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4",
|
||||||
|
expires_at: 30.days.from_now
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
post admin_login_path, params: { username: "ops", password: "secret123" }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra l'archivio replay della società" do
|
||||||
|
get admin_club_recordings_path(club)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("Archivio Replay")
|
||||||
|
expect(response.body).to include("Finale")
|
||||||
|
expect(response.body).to include("Tigers")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "aggiorna titolo e privacy" do
|
||||||
|
patch admin_club_recording_path(club, recording), params: { recording: { title: "Nuovo titolo", privacy_status: "unlisted" } }
|
||||||
|
|
||||||
|
expect(response).to redirect_to(admin_club_recordings_path(club))
|
||||||
|
recording.reload
|
||||||
|
expect(recording.title).to eq("Nuovo titolo")
|
||||||
|
expect(recording.privacy_status).to eq("unlisted")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "elimina un replay" do
|
||||||
|
delete admin_club_recording_path(club, recording)
|
||||||
|
|
||||||
|
expect(response).to redirect_to(admin_club_recordings_path(club))
|
||||||
|
expect(recording.reload.status).to eq("expired")
|
||||||
|
expect(recording.deleted_at).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
36
backend/spec/services/recordings/club_archive_spec.rb
Normal file
36
backend/spec/services/recordings/club_archive_spec.rb
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Recordings::ClubArchive do
|
||||||
|
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:team_a) { club.teams.create!(name: "A", sport: "volleyball") }
|
||||||
|
let!(:team_b) { club.teams.create!(name: "B", sport: "volleyball") }
|
||||||
|
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") }
|
||||||
|
let!(:match_a) { team_a.matches.create!(opponent_name: "Rival A", scheduled_at: 1.day.ago) }
|
||||||
|
let!(:match_b) { team_b.matches.create!(opponent_name: "Rival B", scheduled_at: 1.day.ago) }
|
||||||
|
let!(:session_a) do
|
||||||
|
StreamSession.create!(match: match_a, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago)
|
||||||
|
end
|
||||||
|
let!(:session_b) do
|
||||||
|
StreamSession.create!(match: match_b, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago)
|
||||||
|
end
|
||||||
|
let!(:ready_a) do
|
||||||
|
Recording.create!(
|
||||||
|
stream_session: session_a, team: team_a, status: "ready", privacy_status: "public",
|
||||||
|
storage_key: "teams/#{team_a.id}/sessions/#{session_a.id}/replay.mp4",
|
||||||
|
expires_at: 30.days.from_now
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let!(:processing_b) do
|
||||||
|
Recording.create!(stream_session: session_b, team: team_b, status: "processing", privacy_status: "unlisted")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "filtra per squadra" do
|
||||||
|
archive = described_class.new(club, params: { team_id: team_a.id })
|
||||||
|
expect(archive.recordings).to contain_exactly(ready_a)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "filtra per stato ready" do
|
||||||
|
archive = described_class.new(club, params: { status: "ready" })
|
||||||
|
expect(archive.recordings).to contain_exactly(ready_a)
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user