Aggiunge i18n IT/EN/FR/DE/ES, pagine pubbliche squadre e UX archivio.
Il selettore lingua funziona sul web e sulle app native; su Android la preferenza è persistita e applicata al riavvio. Incluse anche eliminazione replay a scope e campi pagina pubblica squadra. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,7 +14,9 @@ module Api
|
||||
|
||||
def destroy
|
||||
authorize_manage!
|
||||
Recordings::Delete.new(@recording).call
|
||||
scope = params[:delete_scope].to_s
|
||||
scope = "both" unless Recordings::Delete::SCOPES.include?(scope)
|
||||
Recordings::Delete.new(@recording, scope: scope).call
|
||||
head :no_content
|
||||
end
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@ module Recordings
|
||||
end
|
||||
|
||||
def destroy
|
||||
Recordings::Delete.new(@recording).call
|
||||
redirect_to archive_index_path, notice: "Replay eliminato"
|
||||
scope = delete_scope_param
|
||||
Recordings::Delete.new(@recording, scope: scope).call
|
||||
redirect_to archive_index_path, notice: delete_notice_for(scope)
|
||||
end
|
||||
|
||||
def publish_youtube
|
||||
@@ -99,6 +100,22 @@ module Recordings
|
||||
params.require(:recording).permit(:privacy_status, :title, :expires_at, :extend_days)
|
||||
end
|
||||
|
||||
def delete_scope_param
|
||||
value = params[:delete_scope].to_s
|
||||
Recordings::Delete::SCOPES.include?(value) ? value : "both"
|
||||
end
|
||||
|
||||
def delete_notice_for(scope)
|
||||
case scope
|
||||
when "site"
|
||||
"Replay eliminato dal sito (YouTube invariato)"
|
||||
when "youtube"
|
||||
"Video rimosso da YouTube (resta disponibile sul sito)"
|
||||
else
|
||||
"Replay eliminato dal sito e da YouTube"
|
||||
end
|
||||
end
|
||||
|
||||
def admin_expiry_update_requested?
|
||||
return false unless archive_namespace == :admin
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
module Public
|
||||
class LocalesController < SiteBaseController
|
||||
def update
|
||||
locale = LocaleResolver.persist!(cookies, params[:locale])
|
||||
I18n.locale = locale
|
||||
redirect_back fallback_location: root_path
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,14 +5,30 @@ module Public
|
||||
include ::SeoHelper
|
||||
include ::LegalHelper
|
||||
helper ApplicationHelper
|
||||
helper_method :current_user, :logged_in?
|
||||
helper_method :current_user, :logged_in?, :current_locale, :language_options, :current_language_option
|
||||
|
||||
protect_from_forgery with: :exception
|
||||
before_action :set_site_locale
|
||||
|
||||
private
|
||||
|
||||
def set_site_locale
|
||||
I18n.locale = :it
|
||||
I18n.locale = LocaleResolver.resolve(
|
||||
cookie_jar: request.cookie_jar,
|
||||
accept_language: request.get_header("HTTP_ACCEPT_LANGUAGE")
|
||||
)
|
||||
end
|
||||
|
||||
def current_locale
|
||||
I18n.locale
|
||||
end
|
||||
|
||||
def language_options
|
||||
LocaleResolver.language_options
|
||||
end
|
||||
|
||||
def current_language_option
|
||||
LocaleResolver.current_language_option
|
||||
end
|
||||
|
||||
def current_user
|
||||
|
||||
@@ -9,11 +9,20 @@ module Public
|
||||
{ loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" },
|
||||
{ loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" },
|
||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
||||
]
|
||||
|
||||
Team.find_each do |team|
|
||||
@entries << {
|
||||
loc: "#{base}/squadre/#{team.slug}",
|
||||
changefreq: "daily",
|
||||
priority: "0.7"
|
||||
}
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.xml { render layout: false }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
module Public
|
||||
class TeamPagesController < SiteBaseController
|
||||
include Public::LiveHelper
|
||||
|
||||
layout "marketing_live"
|
||||
|
||||
def index
|
||||
@query = params[:q].to_s.strip
|
||||
@sport = params[:sport].to_s.strip.presence
|
||||
@live_filter = params[:live].present?
|
||||
@replays_filter = params[:replays].present?
|
||||
@entries = Teams::PublicDirectory.call(
|
||||
q: @query,
|
||||
sport: @sport,
|
||||
live: @live_filter,
|
||||
replays: @replays_filter
|
||||
)
|
||||
@sport_options = Sports::Catalog.as_api_list
|
||||
end
|
||||
|
||||
def show
|
||||
@team = Team.includes(:club, roster_members: []).find_by!(slug: params[:slug])
|
||||
@club = @team.club
|
||||
load_live_content!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_live_content!
|
||||
@online_paths = fetch_online_paths
|
||||
|
||||
@live_sessions = StreamSession
|
||||
.broadcasting
|
||||
.publicly_listed
|
||||
.where(platform: "matchlivetv")
|
||||
.includes(:score_state, match: { team: :club })
|
||||
.joins(:match)
|
||||
.where(matches: { team_id: @team.id })
|
||||
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
|
||||
|
||||
broadcasting_match_ids = StreamSession.broadcasting.select(:match_id)
|
||||
@upcoming_matches = @team.matches
|
||||
.scheduled_for_live
|
||||
.where.not(id: broadcasting_match_ids)
|
||||
.order(scheduled_at: :asc)
|
||||
.limit(20)
|
||||
|
||||
@recordings = @team.recordings
|
||||
.ready
|
||||
.publicly_listed
|
||||
.includes(stream_session: :match)
|
||||
.order(recorded_at: :desc, created_at: :desc)
|
||||
.limit(12)
|
||||
|
||||
@roster_by_category = @team.roster_public? ? @team.roster_by_category : nil
|
||||
end
|
||||
|
||||
def fetch_online_paths
|
||||
Mediamtx::Client.new.online_path_names
|
||||
rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -165,7 +165,7 @@ module Public
|
||||
end
|
||||
|
||||
def team_params
|
||||
p = params.require(:team).permit(:name, :sport, :sport_key, :description, :logo_url, :primary_color, :secondary_color)
|
||||
p = params.require(:team).permit(:name, :sport, :sport_key, :description, :logo_url, :primary_color, :secondary_color, :slug, :roster_public)
|
||||
if p[:sport].present? && p[:sport_key].blank?
|
||||
p[:sport_key] = p.delete(:sport)
|
||||
elsif p[:sport_key].present?
|
||||
|
||||
@@ -9,12 +9,26 @@ module ApplicationHelper
|
||||
PLAN_ICONS[plan.slug] || "fa-solid fa-circle"
|
||||
end
|
||||
|
||||
# Data/ora nel fuso dell'app (Europe/Rome) e nella lingua del sito.
|
||||
# Data/ora nel fuso dell'app (Europe/Rome) e nella lingua corrente.
|
||||
def l_local(date_or_time, format: :long)
|
||||
return nil if date_or_time.blank?
|
||||
|
||||
value = date_or_time.respond_to?(:in_time_zone) ? date_or_time.in_time_zone : date_or_time
|
||||
I18n.with_locale(:it) { I18n.l(value, format: format) }
|
||||
I18n.l(value, format: format)
|
||||
end
|
||||
|
||||
def og_locale_tag
|
||||
{
|
||||
it: "it_IT",
|
||||
en: "en_US",
|
||||
fr: "fr_FR",
|
||||
de: "de_DE",
|
||||
es: "es_ES"
|
||||
}.fetch(I18n.locale.to_sym, "it_IT")
|
||||
end
|
||||
|
||||
def html_lang
|
||||
I18n.locale.to_s
|
||||
end
|
||||
|
||||
def sport_catalog_options(selected = nil)
|
||||
|
||||
@@ -50,13 +50,18 @@ module Public
|
||||
end
|
||||
end
|
||||
|
||||
def live_match_card_heading(match)
|
||||
def live_match_card_heading(match, link_team: true)
|
||||
team = match.team
|
||||
club_name = team.club&.name.presence || "Società"
|
||||
team_label = if link_team && team.slug.present?
|
||||
link_to(team.name, public_team_page_path(team.slug), class: "live-card__team-link")
|
||||
else
|
||||
team.name
|
||||
end
|
||||
content_tag(:h3, class: "live-card__title") do
|
||||
safe_join([
|
||||
content_tag(:span, club_name, class: "live-card__club"),
|
||||
content_tag(:span, "#{team.name} vs #{match.opponent_name}", class: "live-card__matchup")
|
||||
content_tag(:span, safe_join([team_label, " vs ", match.opponent_name]), class: "live-card__matchup")
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
module Public
|
||||
module TeamPagesHelper
|
||||
def team_page_meta_description(team, club)
|
||||
"Segui #{team.name} (#{club.name}): dirette live, calendario partite e replay su Match Live TV."
|
||||
end
|
||||
|
||||
def filter_params_for_canonical
|
||||
params.permit(:q, :sport, :live, :replays).to_h.compact_blank
|
||||
end
|
||||
|
||||
def team_page_structured_data(team, club)
|
||||
{
|
||||
"@context" => "https://schema.org",
|
||||
"@type" => "SportsTeam",
|
||||
"name" => team.name,
|
||||
"sport" => team.sport_label,
|
||||
"url" => seo_absolute_url(public_team_page_path(team.slug)),
|
||||
"memberOf" => {
|
||||
"@type" => "SportsOrganization",
|
||||
"name" => club.name
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,9 +13,14 @@ class Team < ApplicationRecord
|
||||
|
||||
validates :name, presence: true
|
||||
validates :sport_key, presence: true
|
||||
validates :slug, presence: true, uniqueness: true,
|
||||
format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, message: "solo lettere minuscole, numeri e trattini" }
|
||||
validate :sport_key_known
|
||||
validate :photo_file_type, if: -> { photo_file.attached? }
|
||||
|
||||
before_validation :assign_slug, on: :create
|
||||
before_validation :normalize_slug, if: -> { slug_changed? && slug.present? }
|
||||
|
||||
before_validation :normalize_sport_key
|
||||
|
||||
def subscription
|
||||
@@ -47,6 +52,14 @@ class Team < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def roster_public?
|
||||
roster_public
|
||||
end
|
||||
|
||||
def public_page_path
|
||||
Rails.application.routes.url_helpers.public_team_page_path(slug)
|
||||
end
|
||||
|
||||
def sport_label
|
||||
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
|
||||
end
|
||||
@@ -86,6 +99,14 @@ class Team < ApplicationRecord
|
||||
club
|
||||
end
|
||||
|
||||
def assign_slug
|
||||
self.slug = Teams::GenerateSlug.call(self) if slug.blank?
|
||||
end
|
||||
|
||||
def normalize_slug
|
||||
self.slug = slug.to_s.parameterize
|
||||
end
|
||||
|
||||
def default_primary_color
|
||||
club&.primary_color.presence || super
|
||||
end
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LocaleResolver
|
||||
COOKIE_NAME = "mltv_locale"
|
||||
COOKIE_MAX_AGE = 2.years.to_i
|
||||
|
||||
module_function
|
||||
|
||||
def available
|
||||
I18n.available_locales.map(&:to_sym)
|
||||
end
|
||||
|
||||
def normalize(value)
|
||||
code = value.to_s.strip.downcase.split(/[-_]/).first
|
||||
return nil if code.blank?
|
||||
|
||||
sym = code.to_sym
|
||||
available.include?(sym) ? sym : nil
|
||||
end
|
||||
|
||||
def from_cookie(cookies)
|
||||
return nil if cookies.nil?
|
||||
|
||||
value = if cookies.respond_to?(:[])
|
||||
cookies[COOKIE_NAME]
|
||||
elsif cookies.respond_to?(:fetch)
|
||||
cookies.fetch(COOKIE_NAME, nil)
|
||||
end
|
||||
normalize(value)
|
||||
end
|
||||
|
||||
def from_accept_language(header)
|
||||
return nil if header.blank?
|
||||
|
||||
header.to_s.split(",").each do |part|
|
||||
tag = part.split(";").first.to_s.strip
|
||||
locale = normalize(tag)
|
||||
return locale if locale
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve(cookie_jar: nil, cookies: nil, accept_language: nil)
|
||||
jar = cookie_jar || cookies
|
||||
from_cookie(jar) || from_accept_language(accept_language) || I18n.default_locale
|
||||
end
|
||||
|
||||
def persist!(cookie_jar, locale)
|
||||
normalized = normalize(locale) || I18n.default_locale
|
||||
cookie_jar[COOKIE_NAME] = {
|
||||
value: normalized.to_s,
|
||||
expires: COOKIE_MAX_AGE.seconds.from_now,
|
||||
path: "/",
|
||||
same_site: :lax,
|
||||
httponly: false
|
||||
}
|
||||
normalized
|
||||
end
|
||||
|
||||
def language_options
|
||||
[
|
||||
{ code: :it, label: "Italiano", native: "Italiano", flag: "🇮🇹" },
|
||||
{ code: :en, label: "English", native: "English", flag: "🇬🇧" },
|
||||
{ code: :fr, label: "Français", native: "Français", flag: "🇫🇷" },
|
||||
{ code: :de, label: "Deutsch", native: "Deutsch", flag: "🇩🇪" },
|
||||
{ code: :es, label: "Español", native: "Español", flag: "🇪🇸" }
|
||||
].select { |opt| available.include?(opt[:code]) }
|
||||
end
|
||||
|
||||
def current_language_option
|
||||
language_options.find { |opt| opt[:code] == I18n.locale.to_sym } || language_options.first
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,33 @@
|
||||
module Recordings
|
||||
class Delete
|
||||
def initialize(recording, reason: :manual)
|
||||
SCOPES = %w[both site youtube].freeze
|
||||
|
||||
def initialize(recording, reason: :manual, scope: :both)
|
||||
@recording = recording
|
||||
@reason = reason
|
||||
@scope = normalize_scope(scope)
|
||||
end
|
||||
|
||||
def call
|
||||
case @scope
|
||||
when "youtube"
|
||||
delete_youtube_only!
|
||||
when "site"
|
||||
delete_site_only!
|
||||
else
|
||||
delete_both!
|
||||
end
|
||||
@recording
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_scope(scope)
|
||||
value = scope.to_s.presence || "both"
|
||||
SCOPES.include?(value) ? value : "both"
|
||||
end
|
||||
|
||||
def delete_both!
|
||||
delete_youtube_video
|
||||
delete_storage_object if @recording.storage_key.present?
|
||||
cleanup_local_artifacts
|
||||
@@ -18,10 +40,27 @@ module Recordings
|
||||
youtube_video_id: nil,
|
||||
youtube_published_at: nil
|
||||
)
|
||||
@recording
|
||||
end
|
||||
|
||||
private
|
||||
def delete_site_only!
|
||||
delete_storage_object if @recording.storage_key.present?
|
||||
cleanup_local_artifacts
|
||||
|
||||
@recording.update!(
|
||||
status: "expired",
|
||||
deleted_at: Time.current,
|
||||
storage_key: nil,
|
||||
thumbnail_storage_key: nil
|
||||
)
|
||||
end
|
||||
|
||||
def delete_youtube_only!
|
||||
delete_youtube_video
|
||||
@recording.update!(
|
||||
youtube_video_id: nil,
|
||||
youtube_published_at: nil
|
||||
)
|
||||
end
|
||||
|
||||
def delete_youtube_video
|
||||
return if @recording.youtube_video_id.blank?
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
module Teams
|
||||
class GenerateSlug
|
||||
def self.call(team)
|
||||
new(team).call
|
||||
end
|
||||
|
||||
def initialize(team)
|
||||
@team = team
|
||||
end
|
||||
|
||||
def call
|
||||
base = @team.name.to_s.parameterize.presence || "squadra"
|
||||
slug = base
|
||||
n = 2
|
||||
while conflict?(slug)
|
||||
slug = "#{base}-#{n}"
|
||||
n += 1
|
||||
end
|
||||
slug
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conflict?(slug)
|
||||
scope = Team.where(slug: slug)
|
||||
scope = scope.where.not(id: @team.id) if @team.persisted?
|
||||
scope.exists?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,144 @@
|
||||
module Teams
|
||||
# Elenco pubblico delle squadre con presenza su Match Live TV (diretta, replay o calendario).
|
||||
class PublicDirectory
|
||||
Entry = Struct.new(
|
||||
:team,
|
||||
:live_now,
|
||||
:replay_count,
|
||||
:upcoming_count,
|
||||
:last_activity_at,
|
||||
keyword_init: true
|
||||
)
|
||||
|
||||
def self.call(q: nil, sport: nil, live: false, replays: false)
|
||||
new(q: q, sport: sport, live: live, replays: replays).call
|
||||
end
|
||||
|
||||
def initialize(q: nil, sport: nil, live: false, replays: false)
|
||||
@q = q.to_s.strip
|
||||
@sport = normalize_sport(sport)
|
||||
@live = cast_bool(live)
|
||||
@replays = cast_bool(replays)
|
||||
end
|
||||
|
||||
def call
|
||||
teams = base_teams
|
||||
return [] if teams.empty?
|
||||
|
||||
entries = build_entries(teams)
|
||||
entries = apply_toggle_filters(entries)
|
||||
sort_entries(entries)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_sport(value)
|
||||
key = value.to_s.strip
|
||||
return nil if key.blank?
|
||||
return nil unless Sports::Catalog.find_optional(key)
|
||||
|
||||
Sports::Catalog.normalize_key(key)
|
||||
end
|
||||
|
||||
def cast_bool(value)
|
||||
ActiveModel::Type::Boolean.new.cast(value)
|
||||
end
|
||||
|
||||
def base_teams
|
||||
scope = Team.includes(:club).where(id: active_team_ids)
|
||||
scope = scope.where(sport_key: @sport) if @sport
|
||||
scope = apply_search(scope) if @q.present?
|
||||
scope.to_a
|
||||
end
|
||||
|
||||
def active_team_ids
|
||||
live = live_team_ids
|
||||
replays = replay_team_ids
|
||||
upcoming = upcoming_team_ids
|
||||
(live + replays + upcoming).uniq
|
||||
end
|
||||
|
||||
def live_team_ids
|
||||
StreamSession.broadcasting
|
||||
.publicly_listed
|
||||
.where(platform: "matchlivetv")
|
||||
.joins(:match)
|
||||
.distinct
|
||||
.pluck("matches.team_id")
|
||||
end
|
||||
|
||||
def replay_team_ids
|
||||
Recording.ready.publicly_listed.joins(stream_session: :match).distinct.pluck("matches.team_id")
|
||||
end
|
||||
|
||||
def upcoming_team_ids
|
||||
broadcasting_match_ids = StreamSession.broadcasting.select(:match_id)
|
||||
Match.scheduled_for_live
|
||||
.where.not(id: broadcasting_match_ids)
|
||||
.distinct
|
||||
.pluck(:team_id)
|
||||
end
|
||||
|
||||
def apply_search(scope)
|
||||
term = "%#{ActiveRecord::Base.sanitize_sql_like(@q)}%"
|
||||
scope.left_joins(:club, :matches).where(
|
||||
"teams.name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term OR matches.opponent_name ILIKE :term",
|
||||
term: term
|
||||
).distinct
|
||||
end
|
||||
|
||||
def build_entries(teams)
|
||||
team_ids = teams.map(&:id)
|
||||
live_set = live_team_ids.to_set
|
||||
replay_counts = Recording.ready.publicly_listed
|
||||
.joins(stream_session: :match)
|
||||
.where(matches: { team_id: team_ids })
|
||||
.group("matches.team_id")
|
||||
.count
|
||||
last_replay_at = Recording.ready.publicly_listed
|
||||
.joins(stream_session: :match)
|
||||
.where(matches: { team_id: team_ids })
|
||||
.group("matches.team_id")
|
||||
.maximum(:recorded_at)
|
||||
last_live_at = StreamSession.broadcasting
|
||||
.publicly_listed
|
||||
.joins(:match)
|
||||
.where(matches: { team_id: team_ids })
|
||||
.group("matches.team_id")
|
||||
.maximum(:started_at)
|
||||
broadcasting_match_ids = StreamSession.broadcasting.select(:match_id)
|
||||
upcoming_counts = Match.scheduled_for_live
|
||||
.where(team_id: team_ids)
|
||||
.where.not(id: broadcasting_match_ids)
|
||||
.group(:team_id)
|
||||
.count
|
||||
|
||||
teams.map do |team|
|
||||
last_activity = [last_replay_at[team.id], last_live_at[team.id]].compact.max
|
||||
Entry.new(
|
||||
team: team,
|
||||
live_now: live_set.include?(team.id),
|
||||
replay_count: replay_counts[team.id].to_i,
|
||||
upcoming_count: upcoming_counts[team.id].to_i,
|
||||
last_activity_at: last_activity
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def apply_toggle_filters(entries)
|
||||
entries = entries.select(&:live_now) if @live
|
||||
entries = entries.select { |entry| entry.replay_count.positive? } if @replays
|
||||
entries
|
||||
end
|
||||
|
||||
def sort_entries(entries)
|
||||
entries.sort_by do |entry|
|
||||
[
|
||||
entry.live_now ? 0 : 1,
|
||||
-(entry.last_activity_at&.to_i || 0),
|
||||
entry.team.name.downcase
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,12 +6,14 @@
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="stylesheet" href="/admin.css?v=2">
|
||||
<% if content_for?(:replay_archive_styles) %>
|
||||
<link rel="stylesheet" href="/marketing.css?v=1">
|
||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||
<% end %>
|
||||
<% 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="/admin-dashboard.js?v=1" defer></script>
|
||||
<% end %>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<script src="/confirm-forms.js?v=6" defer></script>
|
||||
</head>
|
||||
<body class="<%= content_for?(:body_class) ? yield(:body_class) : 'admin-body' %>">
|
||||
<header class="admin-header">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<html lang="<%= html_lang %>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -8,7 +8,7 @@
|
||||
<%= render "shared/meta_tags" %>
|
||||
<%= yield :head %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=38">
|
||||
<link rel="stylesheet" href="/marketing.css?v=44">
|
||||
</head>
|
||||
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||
<%= render "shared/cookie_banner" %>
|
||||
@@ -22,6 +22,8 @@
|
||||
<script src="/branding-form.js?v=1" defer></script>
|
||||
<script src="/roster-form.js?v=1" defer></script>
|
||||
<script src="/password-toggle.js?v=2" defer></script>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<script src="/confirm-forms.js?v=6" defer></script>
|
||||
<script src="/cookie-consent.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<html lang="<%= html_lang %>">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=38">
|
||||
<link rel="stylesheet" href="/marketing.css?v=44">
|
||||
<link rel="stylesheet" href="/live.css?v=26">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
@@ -17,6 +17,8 @@
|
||||
<%= yield %>
|
||||
</main>
|
||||
<%= render "shared/marketing_footer" %>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<script src="/confirm-forms.js?v=6" defer></script>
|
||||
<script src="/cookie-consent.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
<%= link_to "Prezzi", public_pricing_path %>
|
||||
<% if logged_in? %>
|
||||
<% if current_user.primary_club %>
|
||||
· <%= link_to "Società", public_club_path(current_user.primary_club) %>
|
||||
· <%= link_to "La mia società", public_club_path(current_user.primary_club) %>
|
||||
<% elsif current_user.manageable_teams.any? %>
|
||||
· <%= link_to "Dettagli squadra", public_team_details_path(current_user.manageable_teams.first) %>
|
||||
· <%= link_to "La mia squadra", public_team_details_path(current_user.manageable_teams.first) %>
|
||||
<% end %>
|
||||
· <%= button_to "Esci", public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||
<% else %>
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
<% end %>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<%= link_to "Pagina pubblica", public_team_page_path(team.slug), target: "_blank", rel: "noopener" %>
|
||||
·
|
||||
<%= link_to "Dettagli", public_team_details_path(team) %>
|
||||
·
|
||||
<%= link_to "Partite", public_team_matches_path(team) %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% content_for :title, "Match Live TV — Diretta live partite giovanili da telefono" %>
|
||||
<% content_for :meta_description, "Non puoi andare in palestra? Guarda la diretta live della partita di tuo figlio dal telefono o dal computer, senza installare app. Pallavolo, calcio e sport giovanili: link da condividere con nonni e parenti, archivio se te la perdi." %>
|
||||
<% content_for :title, t("home.title") %>
|
||||
<% content_for :meta_description, t("home.meta_description") %>
|
||||
<% content_for :canonical_url, seo_absolute_url(root_path) %>
|
||||
<% content_for :head do %>
|
||||
<script type="application/ld+json">
|
||||
@@ -10,7 +10,7 @@
|
||||
"url" => seo_absolute_url(root_path),
|
||||
"applicationCategory" => "SportsApplication",
|
||||
"operatingSystem" => "Web, Android, iOS",
|
||||
"description" => "Piattaforma per dirette live e archivio partite delle società sportive giovanili."
|
||||
"description" => t("home.schema_description")
|
||||
}.to_json) %>
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -20,104 +20,94 @@
|
||||
<div class="hero-content">
|
||||
<p class="hero-app-name">Match Live TV</p>
|
||||
<p class="hero-live-badge"><span class="hero-live-dot" aria-hidden="true"></span> LIVE</p>
|
||||
<h1>Ogni partita, ogni evento, per chi <span class="hero-accent">non può esserci.</span></h1>
|
||||
<p class="tagline">
|
||||
Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici —
|
||||
guardano dal browser, senza installare nulla.
|
||||
E se te la sei persa, la ritrovi nell'archivio.
|
||||
</p>
|
||||
<h1><%= raw t("home.headline_html") %></h1>
|
||||
<p class="tagline"><%= t("home.tagline") %></p>
|
||||
<div class="hero-cta">
|
||||
<%= link_to "Registra la tua squadra", public_signup_path, class: "btn btn-primary" %>
|
||||
<%= link_to "Guarda le dirette", public_live_index_path, class: "btn btn-secondary" %>
|
||||
<%= link_to t("home.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
||||
<%= link_to t("home.cta_live"), public_live_index_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
<ul class="hero-features" aria-label="Vantaggi principali">
|
||||
<ul class="hero-features" aria-label="<%= t('home.features_aria') %>">
|
||||
<li class="hero-feature-item">
|
||||
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||
<span class="hero-feature-label">Dirette stabili</span>
|
||||
<span class="hero-feature-label"><%= t("home.feature_stable") %></span>
|
||||
</li>
|
||||
<li class="hero-feature-item">
|
||||
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-cloud"></i></span>
|
||||
<span class="hero-feature-label">Archivio sicuro</span>
|
||||
<span class="hero-feature-label"><%= t("home.feature_archive") %></span>
|
||||
</li>
|
||||
<li class="hero-feature-item">
|
||||
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-user-group"></i></span>
|
||||
<span class="hero-feature-label">Condividi con chi vuoi</span>
|
||||
<span class="hero-feature-label"><%= t("home.feature_share") %></span>
|
||||
</li>
|
||||
<li class="hero-feature-item hero-feature-item--last">
|
||||
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
|
||||
<span class="hero-feature-label">Tutto dal tuo telefono</span>
|
||||
<span class="hero-feature-label"><%= t("home.feature_phone") %></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="hero-visual">
|
||||
<%= image_tag "/hero-devices.png", alt: "Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra", class: "hero-devices-img", loading: "eager", fetchpriority: "high" %>
|
||||
<%= image_tag "/hero-devices.png", alt: t("home.hero_alt"), class: "hero-devices-img", loading: "eager", fetchpriority: "high" %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap">
|
||||
<h2>Come funziona lo streaming per squadre giovanili</h2>
|
||||
<h2><%= t("home.how_title") %></h2>
|
||||
<div class="steps">
|
||||
<div class="step step--visual">
|
||||
<div class="step-visual">
|
||||
<%= image_tag "/home-step-registra-squadra.png?v=1", alt: "Registrazione squadra: scudo, pallone e modulo con email, password e nome squadra", class: "step-visual-img", loading: "lazy" %>
|
||||
<%= image_tag "/home-step-registra-squadra.png?v=1", alt: t("home.step1_title"), class: "step-visual-img", loading: "lazy" %>
|
||||
</div>
|
||||
<div class="step-num">01</div>
|
||||
<h3>Registra la squadra</h3>
|
||||
<p>La società si iscrive sul sito e sceglie il piano più adatto.</p>
|
||||
<h3><%= t("home.step1_title") %></h3>
|
||||
<p><%= t("home.step1_body") %></p>
|
||||
</div>
|
||||
<div class="step step--visual">
|
||||
<div class="step-visual">
|
||||
<%= image_tag "/home-step-invita-trasmette.png?v=1", alt: "Invito a trasmettere: smartphone con invio email e busta con accetta invito", class: "step-visual-img", loading: "lazy" %>
|
||||
<%= image_tag "/home-step-invita-trasmette.png?v=1", alt: t("home.step2_title"), class: "step-visual-img", loading: "lazy" %>
|
||||
</div>
|
||||
<div class="step-num">02</div>
|
||||
<h3>Invita chi trasmette</h3>
|
||||
<p>Coach e volontari ricevono un invito: ognuno accede con la propria email.</p>
|
||||
<h3><%= t("home.step2_title") %></h3>
|
||||
<p><%= t("home.step2_body") %></p>
|
||||
</div>
|
||||
<div class="step step--visual">
|
||||
<div class="step-visual">
|
||||
<%= image_tag "/home-step-vai-in-diretta.png?v=1", alt: "Vai in diretta: smartphone su treppiede, punteggio live e condivisione link", class: "step-visual-img", loading: "lazy" %>
|
||||
<%= image_tag "/home-step-vai-in-diretta.png?v=1", alt: t("home.step3_title"), class: "step-visual-img", loading: "lazy" %>
|
||||
</div>
|
||||
<div class="step-num">03</div>
|
||||
<h3>Vai in diretta</h3>
|
||||
<p>Dal telefono si avvia la partita: punteggio aggiornato e link da condividere con le famiglie.</p>
|
||||
<h3><%= t("home.step3_title") %></h3>
|
||||
<p><%= t("home.step3_body") %></p>
|
||||
</div>
|
||||
<div class="step step--visual">
|
||||
<div class="step-visual">
|
||||
<%= image_tag "/home-step-tutti-guardano.png?v=2", alt: "Tutti guardano da casa: archivio partite, partita salvata e link per genitori e parenti", class: "step-visual-img", loading: "lazy" %>
|
||||
<%= image_tag "/home-step-tutti-guardano.png?v=2", alt: t("home.step4_title"), class: "step-visual-img", loading: "lazy" %>
|
||||
</div>
|
||||
<div class="step-num">04</div>
|
||||
<h3>Tutti guardano da casa</h3>
|
||||
<p>Genitori, nonni e parenti aprono il link nel browser. La partita resta anche in archivio.</p>
|
||||
<h3><%= t("home.step4_title") %></h3>
|
||||
<p><%= t("home.step4_body") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap plans-teaser">
|
||||
<h2>Piani per dirette live e archivio partite</h2>
|
||||
<p class="plans-teaser-lead">Inizia gratis. Passa a Premium quando vuoi più partite in contemporanea, archivio più lungo e diretta anche su YouTube.</p>
|
||||
<h2><%= t("home.plans_title") %></h2>
|
||||
<p class="plans-teaser-lead"><%= t("home.plans_lead") %></p>
|
||||
<div class="plans-teaser-visual">
|
||||
<%= image_tag "/home-piani-ecosistema.png?v=1", alt: "Ecosistema Match Live TV: smartphone in diretta, laptop con live e statistiche, tablet con archivio partite e cloud", class: "plans-teaser-img", loading: "lazy" %>
|
||||
<%= image_tag "/home-piani-ecosistema.png?v=1", alt: t("home.plans_alt"), class: "plans-teaser-img", loading: "lazy" %>
|
||||
</div>
|
||||
<%= link_to "Confronta i piani", public_prezzi_path, class: "btn btn-primary" %>
|
||||
<%= link_to t("home.plans_cta"), public_prezzi_path, class: "btn btn-primary" %>
|
||||
</section>
|
||||
|
||||
<section class="section wrap seo-prose">
|
||||
<h2>Streaming partite giovanili: perché le famiglie scelgono Match Live TV</h2>
|
||||
<h2><%= t("home.seo_title") %></h2>
|
||||
<p><%= raw t("home.seo_p1_html") %></p>
|
||||
<p><%= raw t("home.seo_p2_html") %></p>
|
||||
<p>
|
||||
Allenatori e dirigenti cercano un modo semplice per mandare in <strong>diretta live</strong> le partite
|
||||
di <strong>pallavolo giovanile</strong>, calcio, basket e altri sport: senza attrezzature da TV,
|
||||
solo con lo smartphone in palestra. Match Live TV è pensato per le società dilettantistiche che vogliono
|
||||
far guardare <strong>Under 14, Under 16, Under 18</strong> e settori giovanili a chi è lontano.
|
||||
</p>
|
||||
<p>
|
||||
Un genitore che lavora, un nonno che non può viaggiare, un parente all’estero: aprono un link e seguono
|
||||
la partita dal browser. Se arrivano in ritardo, la <strong>registrazione in archivio</strong> (con i piani Premium)
|
||||
resta disponibile per giorni. Niente account complicati per chi guarda — solo per lo staff che trasmette.
|
||||
</p>
|
||||
<p>
|
||||
Hai dubbi su come funziona? Leggi le <%= link_to "domande frequenti", public_faq_path %>,
|
||||
scopri <%= link_to "Match Live TV per la pallavolo giovanile", public_pallavolo_path %>
|
||||
o <%= link_to "registra la squadra gratis", public_signup_path %>.
|
||||
<%= raw t(
|
||||
"home.seo_p3_html",
|
||||
faq_link: link_to(t("home.seo_faq_link"), public_faq_path),
|
||||
volleyball_link: link_to(t("home.seo_volleyball_link"), public_pallavolo_path),
|
||||
signup_link: link_to(t("home.seo_signup_link"), public_signup_path)
|
||||
) %>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<% content_for :title, "Squadre su Match Live TV — Dirette e replay sport giovanili" %>
|
||||
<% content_for :meta_description, "Scopri le squadre sportive giovanili che trasmettono su Match Live TV. Cerca per nome, filtra per sport, trova dirette in corso e archivi replay." %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_team_pages_path(filter_params_for_canonical)) %>
|
||||
|
||||
<div class="wrap team-directory">
|
||||
<h1>Squadre su Match Live TV</h1>
|
||||
<p class="results-hint">
|
||||
Società e squadre con dirette, replay pubblici o partite in programma.
|
||||
Cerca la tua squadra e apri la pagina per seguire calendario e archivio.
|
||||
</p>
|
||||
|
||||
<%= form_with url: public_team_pages_path, method: :get, local: true, class: "team-directory-filters" do %>
|
||||
<div class="search-form team-directory-filters__search">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="<%= @query %>"
|
||||
placeholder="Nome squadra, società, città o palestra…"
|
||||
aria-label="Cerca squadra"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary">Cerca</button>
|
||||
</div>
|
||||
|
||||
<div class="team-directory-filters__row">
|
||||
<div class="team-directory-filters__field">
|
||||
<label for="sport" class="team-directory-filters__label">Sport</label>
|
||||
<select name="sport" id="sport" class="team-directory-filters__select" onchange="this.form.requestSubmit()">
|
||||
<option value="">Tutti gli sport</option>
|
||||
<% @sport_options.each do |sport| %>
|
||||
<option value="<%= sport[:key] %>"<%= " selected" if @sport == sport[:key] %>><%= sport[:label] %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="team-directory-filters__field team-directory-filters__field--chips">
|
||||
<span class="team-directory-filters__label" id="team-filter-chips-label">Mostra</span>
|
||||
<div class="team-directory-chips" role="group" aria-labelledby="team-filter-chips-label">
|
||||
<label class="filter-chip<%= " is-active" if @live_filter %>">
|
||||
<%= check_box_tag :live, "1", @live_filter, id: "filter_live", class: "filter-chip__input", onchange: "this.form.requestSubmit()" %>
|
||||
<span class="filter-chip__face" aria-hidden="true">
|
||||
<span class="filter-chip__dot filter-chip__dot--live"></span>
|
||||
In diretta
|
||||
</span>
|
||||
</label>
|
||||
<label class="filter-chip<%= " is-active" if @replays_filter %>">
|
||||
<%= check_box_tag :replays, "1", @replays_filter, id: "filter_replays", class: "filter-chip__input", onchange: "this.form.requestSubmit()" %>
|
||||
<span class="filter-chip__face" aria-hidden="true">
|
||||
<span class="filter-chip__icon">▶</span>
|
||||
Con replay
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @query.present? || @sport.present? || @live_filter || @replays_filter %>
|
||||
<div class="team-directory-filters__actions">
|
||||
<span class="team-directory-filters__label team-directory-filters__label--spacer" aria-hidden="true"> </span>
|
||||
<%= link_to "Azzera", public_team_pages_path, class: "btn btn-secondary team-directory-filters__reset" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @query.present? || @sport.present? || @live_filter || @replays_filter %>
|
||||
<p class="results-hint">
|
||||
<%= @entries.size %> squadre trovate
|
||||
<% if @query.present? %> per «<%= h @query %>»<% end %>
|
||||
<% if @sport.present? %> · <%= Sports::Catalog.find_optional(@sport)&.dig(:label) || @sport %><% end %>
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="results-hint"><%= @entries.size %> squadre attive</p>
|
||||
<% end %>
|
||||
|
||||
<% if @entries.any? %>
|
||||
<div class="team-directory-grid">
|
||||
<% @entries.each do |entry| %>
|
||||
<% team = entry.team %>
|
||||
<% club = team.club %>
|
||||
<% logo = team.effective_logo_url.presence || club.effective_logo_url %>
|
||||
<%= link_to public_team_page_path(team.slug), class: "team-directory-card" do %>
|
||||
<div class="team-directory-card__media">
|
||||
<% if team.team_photo_url.present? %>
|
||||
<%= image_tag team.team_photo_url, alt: "", class: "team-directory-card__photo", loading: "lazy" %>
|
||||
<% elsif logo.present? %>
|
||||
<%= image_tag logo, alt: "", class: "team-directory-card__photo team-directory-card__photo--logo", loading: "lazy" %>
|
||||
<% else %>
|
||||
<div class="team-directory-card__photo team-directory-card__photo--fallback" aria-hidden="true">
|
||||
<%= team.name.first(2).upcase %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="team-directory-card__body">
|
||||
<p class="team-directory-card__club"><%= club.name %></p>
|
||||
<h2 class="team-directory-card__name"><%= team.name %></h2>
|
||||
<p class="team-directory-card__meta"><%= team.sport_label %></p>
|
||||
<div class="team-directory-card__badges">
|
||||
<% if entry.live_now %>
|
||||
<span class="badge badge-on-air">In diretta</span>
|
||||
<% end %>
|
||||
<% if entry.upcoming_count.positive? %>
|
||||
<span class="badge badge-scheduled"><%= entry.upcoming_count %> in programma</span>
|
||||
<% end %>
|
||||
<% if entry.replay_count.positive? %>
|
||||
<span class="badge badge-wait"><%= entry.replay_count %> replay</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="empty-state">
|
||||
<% if @query.present? || @sport.present? || @live_filter || @replays_filter %>
|
||||
<p><strong>Nessuna squadra trovata</strong> con i filtri selezionati.</p>
|
||||
<p><%= link_to "Mostra tutte le squadre attive", public_team_pages_path, class: "btn btn-secondary" %></p>
|
||||
<% else %>
|
||||
<p><strong>Nessuna squadra attiva al momento.</strong></p>
|
||||
<p>Quando una società avvierà dirette o pubblicherà replay, comparirà qui.</p>
|
||||
<%= link_to "Vai alle dirette live", public_live_index_path, class: "btn btn-secondary", style: "margin-top:12px;display:inline-block" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p class="team-directory-footer muted">
|
||||
<%= link_to "Dirette in corso", public_live_index_path %> ·
|
||||
<%= link_to "Archivio replay", public_replay_index_path %>
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,175 @@
|
||||
<% content_for :title, "#{@team.name} — #{@club.name} | Match Live TV" %>
|
||||
<% content_for :meta_description, team_page_meta_description(@team, @club) %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_team_page_path(@team.slug)) %>
|
||||
<% content_for :head do %>
|
||||
<script type="application/ld+json">
|
||||
<%= raw(team_page_structured_data(@team, @club).to_json) %>
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
<div class="wrap team-public-page">
|
||||
<nav class="team-dashboard-nav" aria-label="Percorso">
|
||||
<%= link_to "← Tutte le squadre", public_team_pages_path, class: "team-dashboard-nav__club" %>
|
||||
</nav>
|
||||
|
||||
<header class="roster-hero card team-public-hero" style="--club-primary:<%= @team.effective_primary_color %>;--club-secondary:<%= @team.effective_secondary_color %>">
|
||||
<div class="roster-hero__intro">
|
||||
<p class="roster-hero__club"><%= @club.name %></p>
|
||||
<h1 class="roster-hero__title"><%= @team.name %></h1>
|
||||
<p class="team-public-meta">
|
||||
<%= @team.sport_label %>
|
||||
<% if @live_sessions.any? %>
|
||||
· <span class="hero-live-dot" aria-hidden="true"></span> <strong>In diretta ora</strong>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if @team.description.present? %>
|
||||
<div class="roster-hero__desc"><%= simple_format @team.description %></div>
|
||||
<% end %>
|
||||
<div class="team-public-actions">
|
||||
<% if @live_sessions.any? %>
|
||||
<%= link_to "Guarda la diretta", public_live_path(@live_sessions.first), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<%= link_to "Tutte le dirette", public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %>
|
||||
<% if @recordings.any? %>
|
||||
<%= link_to "Archivio replay", public_replay_index_path(team_id: @team.id), class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="roster-hero__media">
|
||||
<% logo = @team.effective_logo_url.presence || @club.effective_logo_url %>
|
||||
<% if @team.team_photo_url.present? %>
|
||||
<%= image_tag @team.team_photo_url, alt: @team.name, class: "roster-hero__photo" %>
|
||||
<% elsif logo.present? %>
|
||||
<%= image_tag logo, alt: @team.name, class: "roster-hero__photo roster-hero__photo--logo" %>
|
||||
<% else %>
|
||||
<div class="roster-hero__photo roster-hero__photo--empty team-public-logo-fallback" aria-hidden="true">
|
||||
<span><%= @team.name.first(2).upcase %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<% if @live_sessions.any? %>
|
||||
<section class="team-public-section" aria-labelledby="team-live-heading">
|
||||
<h2 id="team-live-heading" class="section-heading">In diretta adesso</h2>
|
||||
<div class="live-grid">
|
||||
<% @live_sessions.each do |session| %>
|
||||
<% match = session.match %>
|
||||
<% on_air = @online_paths.include?(session.mediamtx_path_name) %>
|
||||
<article class="live-card">
|
||||
<%= live_match_card_heading(match, link_team: false) %>
|
||||
<p class="meta">
|
||||
<% if match.location.present? %><%= match.location %> · <% end %>
|
||||
Match Live TV
|
||||
</p>
|
||||
<% if session.score_state %>
|
||||
<p class="card-score">
|
||||
<span class="card-sets"><%= live_score_sets_label(session.score_state, match) %></span>
|
||||
<% if live_score_partials_label(session.score_state).present? %>
|
||||
<span class="card-partials">Parziali: <%= live_score_partials_label(session.score_state) %></span>
|
||||
<% end %>
|
||||
<span class="card-points"><%= session.score_state.home_points %> - <%= session.score_state.away_points %></span>
|
||||
</p>
|
||||
<% end %>
|
||||
<div class="badges">
|
||||
<% if on_air %>
|
||||
<span class="badge badge-on-air">In onda</span>
|
||||
<% elsif session.paused? %>
|
||||
<span class="badge badge-connecting">In pausa</span>
|
||||
<% else %>
|
||||
<span class="badge badge-live">Live</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= link_to "Guarda diretta →", public_live_path(session), class: "btn-watch" %>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<% if @upcoming_matches.any? %>
|
||||
<section class="team-public-section" aria-labelledby="team-upcoming-heading">
|
||||
<h2 id="team-upcoming-heading" class="section-heading<%= " section-heading--spaced" if @live_sessions.any? %>">Prossime partite</h2>
|
||||
<p class="results-hint">Programmate dal club: la diretta partirà quando lo staff avvierà la trasmissione.</p>
|
||||
<div class="live-grid upcoming-grid">
|
||||
<% @upcoming_matches.each do |match| %>
|
||||
<article class="live-card live-card--upcoming">
|
||||
<h3 class="live-card__title">
|
||||
<span class="live-card__matchup"><%= @team.name %> vs <%= match.opponent_name %></span>
|
||||
</h3>
|
||||
<p class="meta">
|
||||
<% if match.location.present? %><%= match.location %> · <% end %>
|
||||
<%= match.category.presence || @team.sport_label %>
|
||||
</p>
|
||||
<p class="upcoming-when"><%= live_scheduled_relative(match.scheduled_at) %></p>
|
||||
<div class="badges">
|
||||
<span class="badge badge-scheduled">In programma</span>
|
||||
</div>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<% if @recordings.any? %>
|
||||
<section class="team-public-section" aria-labelledby="team-replays-heading">
|
||||
<h2 id="team-replays-heading" class="section-heading section-heading--spaced">Replay recenti</h2>
|
||||
<div class="replay-grid">
|
||||
<% @recordings.each do |rec| %>
|
||||
<% match = rec.stream_session.match %>
|
||||
<%= link_to public_replay_path(rec.stream_session_id), class: "replay-card" do %>
|
||||
<div class="replay-card__media">
|
||||
<% if rec.thumbnail_url %>
|
||||
<img src="<%= rec.thumbnail_url %>" alt="" class="replay-card__thumb" loading="lazy" width="320" height="180">
|
||||
<% else %>
|
||||
<div class="replay-card__thumb replay-card__thumb--placeholder" aria-hidden="true">
|
||||
<span class="replay-card__placeholder-icon">▶</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<span class="replay-card__duration"><%= rec.duration_label %></span>
|
||||
<span class="replay-card__play" aria-hidden="true">▶</span>
|
||||
</div>
|
||||
<div class="replay-card__body">
|
||||
<strong class="replay-card__title"><%= rec.title_or_default %></strong>
|
||||
<p class="replay-card__meta"><%= l_local(rec.recorded_at_or_fallback) %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="team-public-more">
|
||||
<%= link_to "Vedi tutti i replay di #{@team.name} →", public_replay_index_path(team_id: @team.id) %>
|
||||
</p>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<% if @roster_by_category %>
|
||||
<section class="team-public-section" aria-labelledby="team-roster-heading">
|
||||
<h2 id="team-roster-heading" class="section-heading section-heading--spaced">Organico</h2>
|
||||
<div class="team-public-roster">
|
||||
<% TeamRosterMember::DISPLAY_ORDER.each do |category| %>
|
||||
<% members = @roster_by_category[category] %>
|
||||
<% next if members.blank? %>
|
||||
<section class="roster-section card">
|
||||
<header class="roster-section__head">
|
||||
<h3 class="roster-section__title"><%= TeamRosterMember::CATEGORY_LABELS[category] %></h3>
|
||||
<span class="roster-section__count"><%= members.size %></span>
|
||||
</header>
|
||||
<div class="roster-list">
|
||||
<% members.each do |person| %>
|
||||
<%= render "shared/roster_person_card", person: person, team: @team, editable: false, compact: true %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<% if @live_sessions.empty? && @upcoming_matches.empty? && @recordings.empty? %>
|
||||
<div class="empty-state empty-state--soft team-public-empty">
|
||||
<p><strong>Nessuna diretta o replay al momento.</strong></p>
|
||||
<p>Torna a controllare prima delle prossime gare: qui compariranno dirette, calendario e archivio della squadra.</p>
|
||||
<%= link_to "Vedi tutte le dirette", public_live_index_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -55,6 +55,7 @@
|
||||
<div class="team-details-actions">
|
||||
<% if @can_manage %>
|
||||
<%= link_to "← Società", public_club_path(@club), class: "btn btn-secondary" %>
|
||||
<%= link_to "Pagina pubblica", public_team_page_path(@team.slug), class: "btn btn-secondary", target: "_blank", rel: "noopener" %>
|
||||
<%= link_to "Responsabili trasmissione", public_team_invite_path(@team), class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<% if current_user.can_stream_for?(@team) %>
|
||||
|
||||
@@ -25,6 +25,21 @@
|
||||
</div>
|
||||
|
||||
<%= render "shared/branding_fields", record: @team, show_inherit_hint: true, legend: "Branding squadra (override)" %>
|
||||
|
||||
<%= label_tag "team[slug]", "Indirizzo pagina pubblica" %>
|
||||
<p class="branding-hint" style="margin-top:0">
|
||||
Genitori e tifosi trovano la squadra su
|
||||
<strong><%= MatchLiveTv.app_public_url.chomp("/") %>/squadre/<span id="team-slug-preview"><%= @team.slug %></span></strong>
|
||||
</p>
|
||||
<%= text_field_tag "team[slug]", @team.slug, pattern: "[a-z0-9]+(-[a-z0-9]+)*", title: "Solo lettere minuscole, numeri e trattini" %>
|
||||
|
||||
<%= label_tag "team[roster_public]", class: "checkbox-label", style: "display:flex;align-items:center;gap:8px;margin-top:16px" do %>
|
||||
<%= hidden_field_tag "team[roster_public]", "0" %>
|
||||
<%= check_box_tag "team[roster_public]", "1", @team.roster_public?, id: "team_roster_public" %>
|
||||
Mostra l'organico sulla pagina pubblica
|
||||
<% end %>
|
||||
<p class="branding-hint">Se attivo, giocatori e staff sono visibili a chiunque abbia il link. Puoi lasciarlo disattivato per privacy.</p>
|
||||
|
||||
<p class="branding-hint">
|
||||
Colori società:
|
||||
<span class="color-swatch" style="background:<%= @club.effective_primary_color %>"></span>
|
||||
|
||||
@@ -135,29 +135,56 @@
|
||||
</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">
|
||||
<% next_privacy = rec.publicly_listed? ? "unlisted" : "public" %>
|
||||
<% privacy_label = rec.publicly_listed? ? "Pubblico" : "Privato" %>
|
||||
<% privacy_hint = rec.publicly_listed? ? "Visibile a tutti — clicca per rendere privato" : "Solo con link — clicca per rendere pubblico" %>
|
||||
<%= button_to paths.update.call(rec),
|
||||
method: :patch,
|
||||
params: filter_params.merge(recording: { privacy_status: next_privacy }),
|
||||
class: "replay-archive__privacy-toggle replay-archive__privacy-toggle--#{rec.privacy_status}",
|
||||
title: "#{privacy_label}: #{privacy_hint}",
|
||||
form: { class: "replay-archive__privacy-form" } do %>
|
||||
<% if rec.publicly_listed? %>
|
||||
<svg class="replay-archive__privacy-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M3 12h18M12 3c2.8 2.6 4.2 5.6 4.2 9s-1.4 6.4-4.2 9c-2.8-2.6-4.2-5.6-4.2-9S9.2 5.6 12 3z" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||
</svg>
|
||||
<% else %>
|
||||
<svg class="replay-archive__privacy-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M8 11V8a4 4 0 0 1 8 0v3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<% end %>
|
||||
<span class="visually-hidden"><%= privacy_label %></span>
|
||||
<% end %>
|
||||
<% 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" %>
|
||||
<%= button_to "YT", paths.publish_youtube.call(rec), method: :post, class: "replay-archive__action replay-archive__action--secondary", title: "Pubblica su YouTube", form: { class: "replay-archive__action-form" } %>
|
||||
<% 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 %>
|
||||
<% has_youtube = rec.youtube_video_id.present? && !rec.youtube_video_id.to_s.start_with?("mock_") %>
|
||||
<% has_site = rec.storage_key.present? || %w[ready processing failed].include?(rec.status) %>
|
||||
<% delete_confirm = "Scegli dove eliminare il replay. L'operazione è irreversibile." %>
|
||||
<%= 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 %>
|
||||
form: {
|
||||
class: "replay-archive__action-form",
|
||||
data: {
|
||||
confirm: delete_confirm,
|
||||
turbo_confirm: delete_confirm,
|
||||
confirm_mode: "delete-replay",
|
||||
has_site: has_site ? "1" : "0",
|
||||
has_youtube: has_youtube ? "1" : "0"
|
||||
}
|
||||
} do %>
|
||||
✕
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
<div id="cookie-banner" class="cookie-banner" role="dialog" aria-labelledby="cookie-banner-title" aria-describedby="cookie-banner-desc" hidden>
|
||||
<div class="cookie-banner__inner">
|
||||
<div class="cookie-banner__text">
|
||||
<p id="cookie-banner-title" class="cookie-banner__title">Cookie e privacy</p>
|
||||
<p id="cookie-banner-title" class="cookie-banner__title"><%= t("cookie.title") %></p>
|
||||
<p id="cookie-banner-desc" class="cookie-banner__desc">
|
||||
Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche
|
||||
<strong>Google Analytics</strong> per statistiche aggregate sul sito.
|
||||
<%= link_to "Cookie policy", public_cookies_path, class: "cookie-banner__link" %>
|
||||
e <%= link_to "Privacy", public_privacy_path, class: "cookie-banner__link" %>.
|
||||
<%= raw t(
|
||||
"cookie.body_html",
|
||||
cookie_link: link_to(t("cookie.policy"), public_cookies_path, class: "cookie-banner__link"),
|
||||
privacy_link: link_to(t("cookie.privacy"), public_privacy_path, class: "cookie-banner__link")
|
||||
) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="cookie-banner__actions">
|
||||
<button type="button" class="btn btn-secondary cookie-banner__btn" data-cookie-reject>
|
||||
Solo necessari
|
||||
<%= t("cookie.reject") %>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary cookie-banner__btn" data-cookie-accept-all>
|
||||
Accetta tutti
|
||||
<%= t("cookie.accept_all") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<%# Selettore lingua a bandiere (allineato a destra nel menu) %>
|
||||
<% current = current_language_option %>
|
||||
<div class="lang-switcher" data-lang-switcher>
|
||||
<button
|
||||
type="button"
|
||||
class="lang-switcher__toggle"
|
||||
id="lang-switcher-toggle"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
aria-controls="lang-switcher-menu"
|
||||
aria-label="<%= t('language.choose') %>: <%= current[:native] %>"
|
||||
title="<%= current[:native] %>"
|
||||
>
|
||||
<span class="lang-switcher__flag" aria-hidden="true"><%= current[:flag] %></span>
|
||||
<span class="lang-switcher__chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="lang-switcher__menu"
|
||||
id="lang-switcher-menu"
|
||||
role="listbox"
|
||||
aria-label="<%= t('language.label') %>"
|
||||
hidden
|
||||
>
|
||||
<% language_options.each do |opt| %>
|
||||
<% selected = opt[:code].to_s == current_locale.to_s %>
|
||||
<%= button_to public_locale_path,
|
||||
method: :patch,
|
||||
params: { locale: opt[:code] },
|
||||
class: "lang-switcher__option#{' is-active' if selected}",
|
||||
form: { class: "lang-switcher__option-form" },
|
||||
role: "option",
|
||||
aria: { selected: selected, label: opt[:native] },
|
||||
title: opt[:native] do %>
|
||||
<span class="lang-switcher__flag" aria-hidden="true"><%= opt[:flag] %></span>
|
||||
<span class="visually-hidden"><%= opt[:native] %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.querySelector("[data-lang-switcher]");
|
||||
if (!root || root.dataset.bound === "1") return;
|
||||
root.dataset.bound = "1";
|
||||
|
||||
var toggle = root.querySelector(".lang-switcher__toggle");
|
||||
var menu = root.querySelector(".lang-switcher__menu");
|
||||
if (!toggle || !menu) return;
|
||||
|
||||
function setOpen(open) {
|
||||
menu.hidden = !open;
|
||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
root.classList.toggle("is-open", open);
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
setOpen(menu.hidden);
|
||||
});
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!root.contains(e.target)) setOpen(false);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -1,20 +1,20 @@
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<strong style="color:#fff">Match Live TV</strong> — Ogni partita, ogni evento, per chi non può esserci
|
||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||
</div>
|
||||
<div>
|
||||
<%= link_to "Prezzi", public_prezzi_path %> ·
|
||||
<%= link_to "Dirette", public_live_index_path %> ·
|
||||
<%= link_to "FAQ", public_faq_path %> ·
|
||||
<%= link_to "Privacy", public_privacy_path %> ·
|
||||
<%= link_to "Cookie", public_cookies_path %> ·
|
||||
<%= link_to "Termini", public_termini_path %>
|
||||
· <button type="button" class="footer-link-btn" data-cookie-manage>Gestisci cookie</button>
|
||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||
<%= link_to t("common.faq"), public_faq_path %> ·
|
||||
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
||||
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
||||
<%= link_to t("common.terms"), public_termini_path %>
|
||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||
</div>
|
||||
<div class="site-footer__legal">
|
||||
<p>© 2026 Emiliano Frascaro – P. IVA 14230270960</p>
|
||||
<p>I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.</p>
|
||||
<p><%= t("footer.copyright") %></p>
|
||||
<p><%= t("footer.responsibility") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -7,35 +7,41 @@
|
||||
<span class="brand" aria-hidden="true">Match <span>Live TV</span></span>
|
||||
<% end %>
|
||||
|
||||
<button type="button" class="nav-toggle" aria-label="Apri menu" aria-expanded="false" aria-controls="site-nav">
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="mast-tools">
|
||||
<button type="button" class="nav-toggle" aria-label="<%= t('nav.open_menu') %>" aria-expanded="false" aria-controls="site-nav">
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="site-nav" class="nav" aria-label="Menu principale" aria-hidden="true">
|
||||
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||
<div class="nav-panel">
|
||||
<%= link_to "Home", root_path, class: (request.path == "/" ? "nav-active" : nil) %>
|
||||
<%= link_to "Funzionalità", public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
|
||||
<%= link_to "Prezzi", public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
||||
<%= link_to "FAQ", public_faq_path, class: (request.path == "/faq" ? "nav-active" : nil) %>
|
||||
<%= link_to "Dirette live", public_live_index_path, class: (live_section ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.home"), root_path, class: (request.path == "/" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.features"), public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.faq"), public_faq_path, class: (request.path == "/faq" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.live"), public_live_index_path, class: (live_section ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.teams"), public_team_pages_path, class: (request.path.start_with?("/squadre") ? "nav-active" : nil) %>
|
||||
<div class="nav-actions">
|
||||
<% if logged_in? %>
|
||||
<% if current_user.primary_club || current_user.manageable_teams.any? %>
|
||||
<% if current_user.primary_club %>
|
||||
<%= link_to "Società", public_club_path(current_user.primary_club), class: "nav-link-item" %>
|
||||
<%= link_to t("nav.my_club"), public_club_path(current_user.primary_club), class: "nav-link-item" %>
|
||||
<% elsif current_user.manageable_teams.first %>
|
||||
<%= link_to "Dettagli squadra", public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= button_to "Esci", public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||
<% else %>
|
||||
<%= link_to "Accedi", public_login_path, class: "nav-link-item" %>
|
||||
<%= link_to "Registra squadra", public_signup_path, class: "btn btn-primary nav-btn" %>
|
||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
||||
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
|
||||
<% end %>
|
||||
<div class="nav-lang">
|
||||
<%= render "shared/language_switcher" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -50,12 +56,14 @@
|
||||
var backdrop = document.getElementById("nav-backdrop");
|
||||
var nav = document.getElementById("site-nav");
|
||||
if (!chrome || !toggle || !nav) return;
|
||||
var openLabel = <%= raw t("nav.open_menu").to_json %>;
|
||||
var closeLabel = <%= raw t("nav.close_menu").to_json %>;
|
||||
|
||||
function setOpen(open) {
|
||||
chrome.classList.toggle("nav-open", open);
|
||||
document.body.classList.toggle("nav-menu-open", open);
|
||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
toggle.setAttribute("aria-label", open ? "Chiudi menu" : "Apri menu");
|
||||
toggle.setAttribute("aria-label", open ? closeLabel : openLabel);
|
||||
nav.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<% description = content_for?(:meta_description) ? yield(:meta_description) : "Diretta live delle partite giovanili dal telefono: genitori e nonni guardano dal browser, senza app. Archivio partite per non perderle." %>
|
||||
<% page_title = content_for?(:title) ? yield(:title) : "Match Live TV — Diretta live partite giovanili" %>
|
||||
<% description = content_for?(:meta_description) ? yield(:meta_description) : t("meta.default_description") %>
|
||||
<% page_title = content_for?(:title) ? yield(:title) : t("meta.default_title") %>
|
||||
<meta name="description" content="<%= description %>">
|
||||
<meta name="application-name" content="Match Live TV">
|
||||
<meta name="robots" content="<%= seo_robots_content %>">
|
||||
@@ -9,9 +9,9 @@
|
||||
<meta property="og:description" content="<%= description %>">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="<%= seo_canonical_url %>">
|
||||
<meta property="og:locale" content="it_IT">
|
||||
<meta property="og:locale" content="<%= og_locale_tag %>">
|
||||
<meta property="og:image" content="<%= seo_og_image_url %>">
|
||||
<meta property="og:image:alt" content="Match Live TV — diretta live partite giovanili da smartphone">
|
||||
<meta property="og:image:alt" content="<%= t('meta.og_image_alt') %>">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="<%= page_title %>">
|
||||
<meta name="twitter:description" content="<%= description %>">
|
||||
|
||||
@@ -28,8 +28,14 @@ module App
|
||||
config.active_job.queue_adapter = :sidekiq
|
||||
config.time_zone = "Europe/Rome"
|
||||
config.i18n.default_locale = :it
|
||||
config.i18n.available_locales = %i[it en]
|
||||
config.i18n.fallbacks = { it: %i[it en], en: %i[en it] }
|
||||
config.i18n.available_locales = %i[it en fr de es]
|
||||
config.i18n.fallbacks = {
|
||||
it: %i[it en],
|
||||
en: %i[en it],
|
||||
fr: %i[fr en it],
|
||||
de: %i[de en it],
|
||||
es: %i[es en it]
|
||||
}
|
||||
config.generators { |g| g.orm :active_record, primary_key_type: :uuid }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Sito in italiano: locale fissa per ogni richiesta web (evita date tipo "July 02, 2026").
|
||||
# Locales disponibili sul sito pubblico (IT default + EN/FR/DE/ES).
|
||||
Rails.application.config.after_initialize do
|
||||
I18n.available_locales = %i[it en]
|
||||
I18n.available_locales = %i[it en fr de es]
|
||||
I18n.default_locale = :it
|
||||
end
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
de:
|
||||
language:
|
||||
label: Sprache
|
||||
choose: Sprache wählen
|
||||
nav:
|
||||
main_menu: Hauptmenü
|
||||
open_menu: Menü öffnen
|
||||
close_menu: Menü schließen
|
||||
home: Start
|
||||
features: Funktionen
|
||||
pricing: Preise
|
||||
faq: FAQ
|
||||
live: Live-Spiele
|
||||
teams: Teams
|
||||
my_club: Mein Verein
|
||||
my_team: Mein Team
|
||||
login: Anmelden
|
||||
logout: Abmelden
|
||||
signup: Team registrieren
|
||||
footer:
|
||||
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
||||
live: Live
|
||||
manage_cookies: Cookies verwalten
|
||||
copyright: "© 2026 Emiliano Frascaro – USt-IdNr. 14230270960"
|
||||
responsibility: Die übertragenen Inhalte liegen in der alleinigen Verantwortung der Sportvereine, die sie veröffentlichen.
|
||||
cookie:
|
||||
title: Cookies und Datenschutz
|
||||
body_html: Wir verwenden notwendige Cookies für Login und Sicherheit. Mit Ihrer Zustimmung aktivieren wir auch <strong>Google Analytics</strong> für aggregierte Website-Statistiken. %{cookie_link} und %{privacy_link}.
|
||||
policy: Cookie-Richtlinie
|
||||
privacy: Datenschutz
|
||||
reject: Nur notwendige
|
||||
accept_all: Alle akzeptieren
|
||||
meta:
|
||||
default_title: Match Live TV — Live-Jugendspiele vom Handy
|
||||
default_description: "Jugendspiele live vom Handy streamen: Eltern und Großeltern schauen im Browser, ohne App. Spielarchiv, damit nichts verloren geht."
|
||||
og_image_alt: Match Live TV — Live-Jugendspiele vom Smartphone
|
||||
home:
|
||||
title: Match Live TV — Live-Jugendspiele vom Handy
|
||||
meta_description: "Können Sie nicht in die Halle? Sehen Sie das Spiel Ihres Kindes live am Handy oder Computer — ohne App. Volleyball, Fußball und Jugendsport: Link mit Großeltern und Verwandten teilen, Replay im Archiv."
|
||||
schema_description: Plattform für Livestreams und Archive von Jugend-Sportvereinen.
|
||||
headline_html: Jedes Spiel, jedes Event, für alle, die <span class="hero-accent">nicht dabei sein können.</span>
|
||||
tagline: "Streamen Sie live vom Smartphone. Großeltern, entfernte Verwandte, Freunde — schauen im Browser, ohne Installation. Und wenn Sie es verpasst haben: es ist im Archiv."
|
||||
cta_signup: Team registrieren
|
||||
cta_live: Live-Spiele ansehen
|
||||
features_aria: Wichtige Vorteile
|
||||
feature_stable: Stabile Streams
|
||||
feature_archive: Sicheres Archiv
|
||||
feature_share: Teilen mit wem Sie wollen
|
||||
feature_phone: Alles vom Handy
|
||||
hero_alt: Smartphone auf Stativ für den Livestream und zweites Handy für den Spielstand in der Halle
|
||||
how_title: So funktioniert Streaming für Jugendteams
|
||||
step1_title: Team registrieren
|
||||
step1_body: Der Verein meldet sich auf der Website an und wählt den passenden Plan.
|
||||
step2_title: Streamer einladen
|
||||
step2_body: "Trainer und Helfer erhalten eine Einladung: jeder meldet sich mit seiner E-Mail an."
|
||||
step3_title: Live gehen
|
||||
step3_body: "Am Handy starten Sie das Spiel: Live-Ergebnis und Link zum Teilen mit Familien."
|
||||
step4_title: Alle schauen von zu Hause
|
||||
step4_body: Eltern, Großeltern und Verwandte öffnen den Link im Browser. Das Spiel bleibt auch im Archiv.
|
||||
plans_title: Pläne für Livestreams und Spielarchiv
|
||||
plans_lead: Kostenlos starten. Mit Premium mehr gleichzeitige Spiele, längeres Archiv und YouTube-Livestream.
|
||||
plans_alt: "Match Live TV Ökosystem: Live-Smartphone, Laptop mit Live und Statistik, Tablet mit Archiv und Cloud"
|
||||
plans_cta: Pläne vergleichen
|
||||
seo_title: "Jugendspiel-Streaming: warum Familien Match Live TV wählen"
|
||||
seo_p1_html: Trainer und Vorstände wollen einfach <strong>live</strong> gehen für <strong>Jugendvolleyball</strong>, Fußball, Basketball und mehr — ohne TV-Technik, nur mit Smartphone in der Halle. Match Live TV ist für Amateurvereine gebaut, die <strong>U14, U16, U18</strong> für Entfernte sichtbar machen wollen.
|
||||
seo_p2_html: "Ein Elternteil bei der Arbeit, ein Großelternteil das nicht reisen kann, ein Verwandter im Ausland: sie öffnen einen Link und folgen dem Spiel im Browser. Bei Verspätung bleibt die <strong>Archivaufnahme</strong> (Premium) Tage verfügbar. Keine komplizierten Konten für Zuschauer — nur für das streamingende Team."
|
||||
seo_p3_html: "Fragen zur Nutzung? Lesen Sie die %{faq_link}, entdecken Sie %{volleyball_link} oder %{signup_link}."
|
||||
seo_faq_link: FAQ
|
||||
seo_volleyball_link: Match Live TV für Jugendvolleyball
|
||||
seo_signup_link: Team kostenlos registrieren
|
||||
auth:
|
||||
login_title: Anmelden
|
||||
email: E-Mail
|
||||
password: Passwort
|
||||
submit_login: Anmelden
|
||||
forgot_password: Passwort vergessen?
|
||||
no_account: Noch kein Konto?
|
||||
signup_link: Team registrieren
|
||||
signup_title: Team registrieren
|
||||
common:
|
||||
privacy: Datenschutz
|
||||
cookies: Cookies
|
||||
terms: AGB
|
||||
pricing: Preise
|
||||
faq: FAQ
|
||||
save: Speichern
|
||||
cancel: Abbrechen
|
||||
delete: Löschen
|
||||
confirm: Bestätigen
|
||||
loading: Wird geladen…
|
||||
error: Es ist ein Fehler aufgetreten
|
||||
back: Zurück
|
||||
@@ -0,0 +1,92 @@
|
||||
en:
|
||||
language:
|
||||
label: Language
|
||||
choose: Choose language
|
||||
nav:
|
||||
main_menu: Main menu
|
||||
open_menu: Open menu
|
||||
close_menu: Close menu
|
||||
home: Home
|
||||
features: Features
|
||||
pricing: Pricing
|
||||
faq: FAQ
|
||||
live: Live matches
|
||||
teams: Teams
|
||||
my_club: My club
|
||||
my_team: My team
|
||||
login: Log in
|
||||
logout: Log out
|
||||
signup: Register a team
|
||||
footer:
|
||||
tagline: Every match, every event, for those who can't be there
|
||||
live: Live
|
||||
manage_cookies: Manage cookies
|
||||
copyright: "© 2026 Emiliano Frascaro – VAT 14230270960"
|
||||
responsibility: Broadcast content is the sole responsibility of the sports clubs that publish it.
|
||||
cookie:
|
||||
title: Cookies and privacy
|
||||
body_html: We use necessary cookies for login and security. With your consent we also enable <strong>Google Analytics</strong> for aggregate site statistics. %{cookie_link} and %{privacy_link}.
|
||||
policy: Cookie policy
|
||||
privacy: Privacy
|
||||
reject: Necessary only
|
||||
accept_all: Accept all
|
||||
meta:
|
||||
default_title: Match Live TV — Live youth sports streaming from your phone
|
||||
default_description: "Live stream youth matches from your phone: parents and grandparents watch in the browser, no app required. Match archive so you never miss a game."
|
||||
og_image_alt: Match Live TV — live youth match streaming from a smartphone
|
||||
home:
|
||||
title: Match Live TV — Live youth matches from your phone
|
||||
meta_description: "Can't make it to the gym? Watch your child's match live from your phone or computer—no app to install. Volleyball, football and youth sports: share a link with grandparents and relatives, and catch the replay if you miss it."
|
||||
schema_description: Platform for live streaming and archiving youth sports club matches.
|
||||
headline_html: Every match, every event, for those who <span class="hero-accent">can't be there.</span>
|
||||
tagline: Stream live from your smartphone. Grandparents, distant relatives, friends — they watch in the browser, with nothing to install. And if you missed it, it's in the archive.
|
||||
cta_signup: Register your team
|
||||
cta_live: Watch live matches
|
||||
features_aria: Key benefits
|
||||
feature_stable: Reliable streams
|
||||
feature_archive: Secure archive
|
||||
feature_share: Share with anyone
|
||||
feature_phone: All from your phone
|
||||
hero_alt: Smartphone on a tripod for the live stream and a second phone for the gym scoreboard
|
||||
how_title: How streaming works for youth teams
|
||||
step1_title: Register the team
|
||||
step1_body: The club signs up on the site and chooses the right plan.
|
||||
step2_title: Invite who streams
|
||||
step2_body: "Coaches and volunteers get an invite: each person signs in with their own email."
|
||||
step3_title: Go live
|
||||
step3_body: "From the phone you start the match: live score and a link to share with families."
|
||||
step4_title: Everyone watches from home
|
||||
step4_body: Parents, grandparents and relatives open the link in the browser. The match also stays in the archive.
|
||||
plans_title: Plans for live streams and match archive
|
||||
plans_lead: Start free. Upgrade to Premium when you need more concurrent matches, longer archive and YouTube streaming.
|
||||
plans_alt: "Match Live TV ecosystem: live smartphone, laptop with live stats, tablet with match archive and cloud"
|
||||
plans_cta: Compare plans
|
||||
seo_title: "Youth match streaming: why families choose Match Live TV"
|
||||
seo_p1_html: Coaches and club managers want a simple way to <strong>go live</strong> for <strong>youth volleyball</strong>, football, basketball and more — no TV gear, just a smartphone in the gym. Match Live TV is built for amateur clubs that want <strong>U14, U16, U18</strong> and youth teams watched by people far away.
|
||||
seo_p2_html: "A parent at work, a grandparent who can't travel, a relative abroad: they open a link and follow the match in the browser. If they arrive late, the <strong>archive recording</strong> (on Premium plans) stays available for days. No complicated accounts for viewers — only for the staff who streams."
|
||||
seo_p3_html: "Questions about how it works? Read the %{faq_link}, discover %{volleyball_link} or %{signup_link}."
|
||||
seo_faq_link: FAQ
|
||||
seo_volleyball_link: Match Live TV for youth volleyball
|
||||
seo_signup_link: register your team for free
|
||||
auth:
|
||||
login_title: Log in
|
||||
email: Email
|
||||
password: Password
|
||||
submit_login: Log in
|
||||
forgot_password: Forgot password?
|
||||
no_account: Don't have an account?
|
||||
signup_link: Register a team
|
||||
signup_title: Register your team
|
||||
common:
|
||||
privacy: Privacy
|
||||
cookies: Cookies
|
||||
terms: Terms
|
||||
pricing: Pricing
|
||||
faq: FAQ
|
||||
save: Save
|
||||
cancel: Cancel
|
||||
delete: Delete
|
||||
confirm: Confirm
|
||||
loading: Loading…
|
||||
error: Something went wrong
|
||||
back: Back
|
||||
@@ -0,0 +1,92 @@
|
||||
es:
|
||||
language:
|
||||
label: Idioma
|
||||
choose: Elegir idioma
|
||||
nav:
|
||||
main_menu: Menú principal
|
||||
open_menu: Abrir menú
|
||||
close_menu: Cerrar menú
|
||||
home: Inicio
|
||||
features: Funciones
|
||||
pricing: Precios
|
||||
faq: FAQ
|
||||
live: Directos
|
||||
teams: Equipos
|
||||
my_club: Mi club
|
||||
my_team: Mi equipo
|
||||
login: Acceder
|
||||
logout: Salir
|
||||
signup: Registrar equipo
|
||||
footer:
|
||||
tagline: Cada partido, cada evento, para quien no puede estar
|
||||
live: Directos
|
||||
manage_cookies: Gestionar cookies
|
||||
copyright: "© 2026 Emiliano Frascaro – NIF 14230270960"
|
||||
responsibility: Los contenidos emitidos son responsabilidad exclusiva de los clubes deportivos que los publican.
|
||||
cookie:
|
||||
title: Cookies y privacidad
|
||||
body_html: Usamos cookies necesarias para el inicio de sesión y la seguridad. Con tu consentimiento también activamos <strong>Google Analytics</strong> para estadísticas agregadas del sitio. %{cookie_link} y %{privacy_link}.
|
||||
policy: Política de cookies
|
||||
privacy: Privacidad
|
||||
reject: Solo necesarias
|
||||
accept_all: Aceptar todas
|
||||
meta:
|
||||
default_title: Match Live TV — Directo de partidos juveniles desde el móvil
|
||||
default_description: "Emite en directo partidos juveniles desde el móvil: padres y abuelos miran en el navegador, sin app. Archivo de partidos para no perdértelos."
|
||||
og_image_alt: Match Live TV — directo de partidos juveniles desde el smartphone
|
||||
home:
|
||||
title: Match Live TV — Directo de partidos juveniles desde el móvil
|
||||
meta_description: "¿No puedes ir al pabellón? Mira el partido de tu hijo en directo desde el móvil o el ordenador, sin instalar apps. Voleibol, fútbol y deporte juvenil: comparte el enlace con abuelos y familiares, y recupera la repetición si te lo perdiste."
|
||||
schema_description: Plataforma de directos y archivo de partidos de clubes deportivos juveniles.
|
||||
headline_html: Cada partido, cada evento, para quien <span class="hero-accent">no puede estar.</span>
|
||||
tagline: Emite en directo desde el smartphone. Abuelos, familiares lejanos, amigos — miran en el navegador, sin instalar nada. Y si te lo perdiste, está en el archivo.
|
||||
cta_signup: Registra tu equipo
|
||||
cta_live: Ver directos
|
||||
features_aria: Ventajas principales
|
||||
feature_stable: Directos estables
|
||||
feature_archive: Archivo seguro
|
||||
feature_share: Comparte con quien quieras
|
||||
feature_phone: Todo desde tu móvil
|
||||
hero_alt: Smartphone en trípode para el directo y segundo móvil para el marcador en el pabellón
|
||||
how_title: Cómo funciona el streaming para equipos juveniles
|
||||
step1_title: Registra el equipo
|
||||
step1_body: El club se registra en el sitio y elige el plan adecuado.
|
||||
step2_title: Invita a quien emite
|
||||
step2_body: "Entrenadores y voluntarios reciben una invitación: cada uno accede con su email."
|
||||
step3_title: Sal en directo
|
||||
step3_body: "Desde el móvil arrancas el partido: marcador en vivo y enlace para compartir con las familias."
|
||||
step4_title: Todos miran desde casa
|
||||
step4_body: Padres, abuelos y familiares abren el enlace en el navegador. El partido también queda en el archivo.
|
||||
plans_title: Planes para directos y archivo de partidos
|
||||
plans_lead: Empieza gratis. Pasa a Premium para más partidos a la vez, archivo más largo y directo en YouTube.
|
||||
plans_alt: "Ecosistema Match Live TV: smartphone en directo, portátil con live y estadísticas, tablet con archivo y nube"
|
||||
plans_cta: Comparar planes
|
||||
seo_title: "Streaming de partidos juveniles: por qué las familias eligen Match Live TV"
|
||||
seo_p1_html: Entrenadores y directivos buscan una forma sencilla de emitir en <strong>directo</strong> partidos de <strong>voleibol juvenil</strong>, fútbol, baloncesto y más — sin equipo de TV, solo con el smartphone en el pabellón. Match Live TV está pensado para clubes amateur que quieren que vean los <strong>U14, U16, U18</strong> desde lejos.
|
||||
seo_p2_html: "Un padre en el trabajo, un abuelo que no puede viajar, un familiar en el extranjero: abren un enlace y siguen el partido en el navegador. Si llegan tarde, la <strong>grabación en archivo</strong> (planes Premium) permanece días. Sin cuentas complicadas para quien mira — solo para el staff que emite."
|
||||
seo_p3_html: "¿Dudas sobre cómo funciona? Lee las %{faq_link}, descubre %{volleyball_link} o %{signup_link}."
|
||||
seo_faq_link: preguntas frecuentes
|
||||
seo_volleyball_link: Match Live TV para voleibol juvenil
|
||||
seo_signup_link: registra el equipo gratis
|
||||
auth:
|
||||
login_title: Acceder
|
||||
email: Email
|
||||
password: Contraseña
|
||||
submit_login: Acceder
|
||||
forgot_password: ¿Olvidaste la contraseña?
|
||||
no_account: ¿No tienes cuenta?
|
||||
signup_link: Registra el equipo
|
||||
signup_title: Registra tu equipo
|
||||
common:
|
||||
privacy: Privacidad
|
||||
cookies: Cookies
|
||||
terms: Términos
|
||||
pricing: Precios
|
||||
faq: FAQ
|
||||
save: Guardar
|
||||
cancel: Cancelar
|
||||
delete: Eliminar
|
||||
confirm: Confirmar
|
||||
loading: Cargando…
|
||||
error: Se ha producido un error
|
||||
back: Atrás
|
||||
@@ -0,0 +1,92 @@
|
||||
fr:
|
||||
language:
|
||||
label: Langue
|
||||
choose: Choisir la langue
|
||||
nav:
|
||||
main_menu: Menu principal
|
||||
open_menu: Ouvrir le menu
|
||||
close_menu: Fermer le menu
|
||||
home: Accueil
|
||||
features: Fonctionnalités
|
||||
pricing: Tarifs
|
||||
faq: FAQ
|
||||
live: Directs
|
||||
teams: Équipes
|
||||
my_club: Mon club
|
||||
my_team: Mon équipe
|
||||
login: Connexion
|
||||
logout: Déconnexion
|
||||
signup: Inscrire une équipe
|
||||
footer:
|
||||
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
||||
live: Directs
|
||||
manage_cookies: Gérer les cookies
|
||||
copyright: "© 2026 Emiliano Frascaro – TVA 14230270960"
|
||||
responsibility: Les contenus diffusés relèvent de la seule responsabilité des clubs sportifs qui les publient.
|
||||
cookie:
|
||||
title: Cookies et confidentialité
|
||||
body_html: Nous utilisons des cookies nécessaires pour la connexion et la sécurité. Avec votre consentement, nous activons aussi <strong>Google Analytics</strong> pour des statistiques agrégées. %{cookie_link} et %{privacy_link}.
|
||||
policy: Politique cookies
|
||||
privacy: Confidentialité
|
||||
reject: Nécessaires uniquement
|
||||
accept_all: Tout accepter
|
||||
meta:
|
||||
default_title: Match Live TV — Direct des matchs jeunes depuis le téléphone
|
||||
default_description: "Diffusez en direct les matchs jeunes depuis le téléphone : parents et grands-parents regardent dans le navigateur, sans application. Archive des matchs pour ne rien manquer."
|
||||
og_image_alt: Match Live TV — direct des matchs jeunes depuis un smartphone
|
||||
home:
|
||||
title: Match Live TV — Direct des matchs jeunes depuis le téléphone
|
||||
meta_description: "Vous ne pouvez pas aller au gymnase ? Regardez le match de votre enfant en direct depuis le téléphone ou l'ordinateur, sans installer d'appli. Volleyball, football et sports jeunes : partagez le lien avec les proches, et retrouvez le replay si vous l'avez manqué."
|
||||
schema_description: Plateforme de direct et d'archivage des matchs des clubs sportifs jeunes.
|
||||
headline_html: Chaque match, chaque événement, pour ceux qui <span class="hero-accent">ne peuvent pas être là.</span>
|
||||
tagline: Diffusez en direct depuis votre smartphone. Les grands-parents, la famille éloignée, les amis — regardent dans le navigateur, sans rien installer. Et si vous l'avez manqué, c'est dans l'archive.
|
||||
cta_signup: Inscrire votre équipe
|
||||
cta_live: Voir les directs
|
||||
features_aria: Avantages principaux
|
||||
feature_stable: Directs stables
|
||||
feature_archive: Archive sécurisée
|
||||
feature_share: Partagez avec qui vous voulez
|
||||
feature_phone: Tout depuis votre téléphone
|
||||
hero_alt: Smartphone sur trépied pour le direct et second téléphone pour le score en salle
|
||||
how_title: Comment fonctionne le streaming pour les équipes jeunes
|
||||
step1_title: Inscrire l'équipe
|
||||
step1_body: Le club s'inscrit sur le site et choisit l'offre adaptée.
|
||||
step2_title: Inviter qui diffuse
|
||||
step2_body: "Coachs et bénévoles reçoivent une invitation : chacun se connecte avec son email."
|
||||
step3_title: Passer en direct
|
||||
step3_body: "Depuis le téléphone, démarrez le match : score en direct et lien à partager avec les familles."
|
||||
step4_title: Tout le monde regarde de chez soi
|
||||
step4_body: Parents, grands-parents et proches ouvrent le lien dans le navigateur. Le match reste aussi dans l'archive.
|
||||
plans_title: Offres pour directs et archive des matchs
|
||||
plans_lead: Commencez gratuitement. Passez en Premium pour plus de matchs simultanés, une archive plus longue et le direct YouTube.
|
||||
plans_alt: "Écosystème Match Live TV : smartphone en direct, laptop avec live et stats, tablette avec archive et cloud"
|
||||
plans_cta: Comparer les offres
|
||||
seo_title: "Streaming des matchs jeunes : pourquoi les familles choisissent Match Live TV"
|
||||
seo_p1_html: Entraîneurs et dirigeants veulent un moyen simple de passer en <strong>direct</strong> les matchs de <strong>volley jeunes</strong>, football, basket et plus — sans matériel TV, juste un smartphone en salle. Match Live TV est pensé pour les clubs amateurs qui veulent faire regarder les <strong>U14, U16, U18</strong> à distance.
|
||||
seo_p2_html: "Un parent au travail, un grand-parent qui ne peut pas voyager, un proche à l'étranger : ils ouvrent un lien et suivent le match dans le navigateur. S'ils arrivent en retard, l'<strong>enregistrement en archive</strong> (offres Premium) reste disponible des jours. Pas de compte compliqué pour les spectateurs — seulement pour le staff qui diffuse."
|
||||
seo_p3_html: "Des questions ? Lisez les %{faq_link}, découvrez %{volleyball_link} ou %{signup_link}."
|
||||
seo_faq_link: FAQ
|
||||
seo_volleyball_link: Match Live TV pour le volley jeunes
|
||||
seo_signup_link: inscrivez l'équipe gratuitement
|
||||
auth:
|
||||
login_title: Connexion
|
||||
email: E-mail
|
||||
password: Mot de passe
|
||||
submit_login: Connexion
|
||||
forgot_password: Mot de passe oublié ?
|
||||
no_account: Pas encore de compte ?
|
||||
signup_link: Inscrire l'équipe
|
||||
signup_title: Inscrire votre équipe
|
||||
common:
|
||||
privacy: Confidentialité
|
||||
cookies: Cookies
|
||||
terms: Conditions
|
||||
pricing: Tarifs
|
||||
faq: FAQ
|
||||
save: Enregistrer
|
||||
cancel: Annuler
|
||||
delete: Supprimer
|
||||
confirm: Confirmer
|
||||
loading: Chargement…
|
||||
error: Une erreur s'est produite
|
||||
back: Retour
|
||||
@@ -0,0 +1,92 @@
|
||||
it:
|
||||
language:
|
||||
label: Lingua
|
||||
choose: Scegli la lingua
|
||||
nav:
|
||||
main_menu: Menu principale
|
||||
open_menu: Apri menu
|
||||
close_menu: Chiudi menu
|
||||
home: Home
|
||||
features: Funzionalità
|
||||
pricing: Prezzi
|
||||
faq: FAQ
|
||||
live: Dirette live
|
||||
teams: Squadre
|
||||
my_club: La mia società
|
||||
my_team: La mia squadra
|
||||
login: Accedi
|
||||
logout: Esci
|
||||
signup: Registra squadra
|
||||
footer:
|
||||
tagline: Ogni partita, ogni evento, per chi non può esserci
|
||||
live: Dirette
|
||||
manage_cookies: Gestisci cookie
|
||||
copyright: "© 2026 Emiliano Frascaro – P. IVA 14230270960"
|
||||
responsibility: I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.
|
||||
cookie:
|
||||
title: Cookie e privacy
|
||||
body_html: Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche <strong>Google Analytics</strong> per statistiche aggregate sul sito. %{cookie_link} e %{privacy_link}.
|
||||
policy: Cookie policy
|
||||
privacy: Privacy
|
||||
reject: Solo necessari
|
||||
accept_all: Accetta tutti
|
||||
meta:
|
||||
default_title: Match Live TV — Diretta live partite giovanili
|
||||
default_description: "Diretta live delle partite giovanili dal telefono: genitori e nonni guardano dal browser, senza app. Archivio partite per non perderle."
|
||||
og_image_alt: Match Live TV — diretta live partite giovanili da smartphone
|
||||
home:
|
||||
title: Match Live TV — Diretta live partite giovanili da telefono
|
||||
meta_description: "Non puoi andare in palestra? Guarda la diretta live della partita di tuo figlio dal telefono o dal computer, senza installare app. Pallavolo, calcio e sport giovanili: link da condividere con nonni e parenti, archivio se te la perdi."
|
||||
schema_description: Piattaforma per dirette live e archivio partite delle società sportive giovanili.
|
||||
headline_html: Ogni partita, ogni evento, per chi <span class="hero-accent">non può esserci.</span>
|
||||
tagline: Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici — guardano dal browser, senza installare nulla. E se te la sei persa, la ritrovi nell'archivio.
|
||||
cta_signup: Registra la tua squadra
|
||||
cta_live: Guarda le dirette
|
||||
features_aria: Vantaggi principali
|
||||
feature_stable: Dirette stabili
|
||||
feature_archive: Archivio sicuro
|
||||
feature_share: Condividi con chi vuoi
|
||||
feature_phone: Tutto dal tuo telefono
|
||||
hero_alt: Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra
|
||||
how_title: Come funziona lo streaming per squadre giovanili
|
||||
step1_title: Registra la squadra
|
||||
step1_body: La società si iscrive sul sito e sceglie il piano più adatto.
|
||||
step2_title: Invita chi trasmette
|
||||
step2_body: "Coach e volontari ricevono un invito: ognuno accede con la propria email."
|
||||
step3_title: Vai in diretta
|
||||
step3_body: "Dal telefono si avvia la partita: punteggio aggiornato e link da condividere con le famiglie."
|
||||
step4_title: Tutti guardano da casa
|
||||
step4_body: Genitori, nonni e parenti aprono il link nel browser. La partita resta anche in archivio.
|
||||
plans_title: Piani per dirette live e archivio partite
|
||||
plans_lead: Inizia gratis. Passa a Premium quando vuoi più partite in contemporanea, archivio più lungo e diretta anche su YouTube.
|
||||
plans_alt: "Ecosistema Match Live TV: smartphone in diretta, laptop con live e statistiche, tablet con archivio partite e cloud"
|
||||
plans_cta: Confronta i piani
|
||||
seo_title: "Streaming partite giovanili: perché le famiglie scelgono Match Live TV"
|
||||
seo_p1_html: "Allenatori e dirigenti cercano un modo semplice per mandare in <strong>diretta live</strong> le partite di <strong>pallavolo giovanile</strong>, calcio, basket e altri sport: senza attrezzature da TV, solo con lo smartphone in palestra. Match Live TV è pensato per le società dilettantistiche che vogliono far guardare <strong>Under 14, Under 16, Under 18</strong> e settori giovanili a chi è lontano."
|
||||
seo_p2_html: "Un genitore che lavora, un nonno che non può viaggiare, un parente all’estero: aprono un link e seguono la partita dal browser. Se arrivano in ritardo, la <strong>registrazione in archivio</strong> (con i piani Premium) resta disponibile per giorni. Niente account complicati per chi guarda — solo per lo staff che trasmette."
|
||||
seo_p3_html: "Hai dubbi su come funziona? Leggi le %{faq_link}, scopri %{volleyball_link} o %{signup_link}."
|
||||
seo_faq_link: domande frequenti
|
||||
seo_volleyball_link: Match Live TV per la pallavolo giovanile
|
||||
seo_signup_link: registra la squadra gratis
|
||||
auth:
|
||||
login_title: Accedi
|
||||
email: Email
|
||||
password: Password
|
||||
submit_login: Accedi
|
||||
forgot_password: Password dimenticata?
|
||||
no_account: Non hai un account?
|
||||
signup_link: Registra la squadra
|
||||
signup_title: Registra la tua squadra
|
||||
common:
|
||||
privacy: Privacy
|
||||
cookies: Cookie
|
||||
terms: Termini
|
||||
pricing: Prezzi
|
||||
faq: FAQ
|
||||
save: Salva
|
||||
cancel: Annulla
|
||||
delete: Elimina
|
||||
confirm: Conferma
|
||||
loading: Caricamento…
|
||||
error: Si è verificato un errore
|
||||
back: Indietro
|
||||
@@ -123,6 +123,9 @@ Rails.application.routes.draw do
|
||||
get "live", to: "public/live#index", as: :public_live_index
|
||||
get "live/:id", to: "public/live#show", as: :public_live
|
||||
get "live/:id/status.json", to: "public/live#status", as: :public_live_status
|
||||
get "squadre", to: "public/team_pages#index", as: :public_team_pages
|
||||
get "squadre/:slug", to: "public/team_pages#show", as: :public_team_page,
|
||||
constraints: { slug: /[a-z0-9]+(?:-[a-z0-9]+)*/ }
|
||||
get "replay", to: "public/replay#index", as: :public_replay_index
|
||||
get "replay/:id", to: "public/replay#show", as: :public_replay
|
||||
get "replay/:id/stream", to: "public/replay#stream", as: :public_replay_stream
|
||||
@@ -154,6 +157,7 @@ Rails.application.routes.draw do
|
||||
get "login", to: "sessions#new"
|
||||
post "login", to: "sessions#create"
|
||||
delete "logout", to: "sessions#destroy"
|
||||
patch "locale", to: "locales#update", as: :locale
|
||||
get "password/forgot", to: "password_resets#new", as: :password_forgot
|
||||
post "password/forgot", to: "password_resets#create"
|
||||
get "password/reset", to: "password_resets#edit", as: :password_reset
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
class AddPublicPageFieldsToTeams < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
add_column :teams, :slug, :string
|
||||
add_column :teams, :roster_public, :boolean, default: false, null: false
|
||||
|
||||
Team.reset_column_information
|
||||
used = {}
|
||||
Team.find_each do |team|
|
||||
base = team.name.to_s.parameterize.presence || "squadra"
|
||||
slug = base
|
||||
n = 2
|
||||
while used[slug]
|
||||
slug = "#{base}-#{n}"
|
||||
n += 1
|
||||
end
|
||||
used[slug] = true
|
||||
team.update_columns(slug: slug)
|
||||
end
|
||||
|
||||
change_column_null :teams, :slug, false
|
||||
add_index :teams, :slug, unique: true
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :teams, :slug
|
||||
remove_column :teams, :roster_public
|
||||
remove_column :teams, :slug
|
||||
end
|
||||
end
|
||||
Generated
+4
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_06_11_120000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -353,7 +353,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_11_120000) do
|
||||
t.string "primary_color"
|
||||
t.string "secondary_color"
|
||||
t.text "description"
|
||||
t.string "slug", null: false
|
||||
t.boolean "roster_public", default: false, null: false
|
||||
t.index ["club_id"], name: "index_teams_on_club_id"
|
||||
t.index ["slug"], name: "index_teams_on_slug", unique: true
|
||||
end
|
||||
|
||||
create_table "user_teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
.site-confirm {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.site-confirm[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.site-confirm__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.site-confirm__panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, 420px);
|
||||
padding: 24px 24px 20px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #2a2a36;
|
||||
background: #16161e;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
color: #f2f2f5;
|
||||
}
|
||||
.site-confirm__eyebrow {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #e53935;
|
||||
}
|
||||
.site-confirm__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.25;
|
||||
color: #fff;
|
||||
}
|
||||
.site-confirm__message {
|
||||
margin: 0 0 22px;
|
||||
color: #b8b8c4;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.site-confirm__panel--choices {
|
||||
width: min(100%, 460px);
|
||||
}
|
||||
.site-confirm__choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.site-confirm__choices[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.site-confirm__choice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #3a3a48;
|
||||
background: #1c1c26;
|
||||
color: #f2f2f5;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.site-confirm__choice:hover,
|
||||
.site-confirm__choice:focus-visible {
|
||||
border-color: #6a6a7a;
|
||||
background: #22222e;
|
||||
outline: none;
|
||||
}
|
||||
.site-confirm__choice--danger {
|
||||
border-color: rgba(229, 57, 53, 0.45);
|
||||
background: rgba(229, 57, 53, 0.12);
|
||||
}
|
||||
.site-confirm__choice--danger:hover,
|
||||
.site-confirm__choice--danger:focus-visible {
|
||||
border-color: #e53935;
|
||||
background: rgba(229, 57, 53, 0.2);
|
||||
}
|
||||
.site-confirm__choice--disabled,
|
||||
.site-confirm__choice:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
.site-confirm__choice[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.site-confirm__choice-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.site-confirm__choice-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #9a9aaa;
|
||||
}
|
||||
.site-confirm__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.site-confirm__btn[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.site-confirm__btn {
|
||||
min-width: 110px;
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.site-confirm__btn.btn-secondary,
|
||||
.site-confirm__btn[data-confirm-cancel] {
|
||||
background: #22222c;
|
||||
border-color: #3a3a48;
|
||||
color: #eee;
|
||||
}
|
||||
.site-confirm__btn.btn-secondary:hover,
|
||||
.site-confirm__btn[data-confirm-cancel]:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
.site-confirm__btn--danger {
|
||||
background: #e53935 !important;
|
||||
border-color: #e53935 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.site-confirm__btn--danger:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
body.site-confirm-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.site-confirm__actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.site-confirm__btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
(function () {
|
||||
var pendingForm = null;
|
||||
var pendingScope = null;
|
||||
var dialog = null;
|
||||
|
||||
function ensureDialog() {
|
||||
if (dialog) return dialog;
|
||||
|
||||
dialog = document.createElement("div");
|
||||
dialog.className = "site-confirm";
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
dialog.setAttribute("aria-labelledby", "site-confirm-title");
|
||||
dialog.hidden = true;
|
||||
dialog.innerHTML =
|
||||
'<div class="site-confirm__backdrop" data-confirm-cancel></div>' +
|
||||
'<div class="site-confirm__panel site-confirm__panel--choices">' +
|
||||
' <p class="site-confirm__eyebrow">Conferma richiesta</p>' +
|
||||
' <h2 class="site-confirm__title" id="site-confirm-title">Sei sicuro?</h2>' +
|
||||
' <p class="site-confirm__message" id="site-confirm-message"></p>' +
|
||||
' <div class="site-confirm__choices" id="site-confirm-choices" hidden>' +
|
||||
' <button type="button" class="site-confirm__choice" data-confirm-scope="site">' +
|
||||
" <span class=\"site-confirm__choice-label\">Solo dal sito</span>" +
|
||||
" <span class=\"site-confirm__choice-hint\" data-hint-site>Il video resta su YouTube</span>" +
|
||||
" </button>" +
|
||||
' <button type="button" class="site-confirm__choice" data-confirm-scope="youtube">' +
|
||||
" <span class=\"site-confirm__choice-label\">Solo da YouTube</span>" +
|
||||
" <span class=\"site-confirm__choice-hint\" data-hint-youtube>Resta disponibile sul sito</span>" +
|
||||
" </button>" +
|
||||
' <button type="button" class="site-confirm__choice site-confirm__choice--danger" data-confirm-scope="both">' +
|
||||
" <span class=\"site-confirm__choice-label\">Da sito e YouTube</span>" +
|
||||
" <span class=\"site-confirm__choice-hint\" data-hint-both>Eliminazione completa</span>" +
|
||||
" </button>" +
|
||||
" </div>" +
|
||||
' <div class="site-confirm__actions">' +
|
||||
' <button type="button" class="btn btn-secondary site-confirm__btn" data-confirm-cancel>Annulla</button>' +
|
||||
' <button type="button" class="btn btn-primary site-confirm__btn site-confirm__btn--danger" data-confirm-ok>Elimina</button>' +
|
||||
" </div>" +
|
||||
"</div>";
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
dialog.addEventListener("click", function (event) {
|
||||
var target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest("[data-confirm-cancel]")) {
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
var scopeBtn = target.closest("[data-confirm-scope]");
|
||||
if (scopeBtn) {
|
||||
if (scopeBtn.disabled || scopeBtn.getAttribute("aria-disabled") === "true") return;
|
||||
submitPending(scopeBtn.getAttribute("data-confirm-scope"));
|
||||
return;
|
||||
}
|
||||
if (target.closest("[data-confirm-ok]")) {
|
||||
submitPending(pendingScope || "both");
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (!dialog || dialog.hidden) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function setChoiceVisible(btn, visible) {
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
btn.hidden = !visible;
|
||||
if (!visible) {
|
||||
btn.disabled = true;
|
||||
btn.setAttribute("aria-disabled", "true");
|
||||
}
|
||||
}
|
||||
|
||||
function setChoiceEnabled(btn, enabled) {
|
||||
if (!(btn instanceof HTMLButtonElement)) return;
|
||||
btn.disabled = !enabled;
|
||||
btn.setAttribute("aria-disabled", enabled ? "false" : "true");
|
||||
btn.classList.toggle("site-confirm__choice--disabled", !enabled);
|
||||
}
|
||||
|
||||
function openDialog(message, options) {
|
||||
options = options || {};
|
||||
var hasSite = options.hasSite !== false;
|
||||
var hasYoutube = !!options.hasYoutube;
|
||||
var canSite = hasSite;
|
||||
var canYoutube = hasYoutube;
|
||||
var canBoth = hasSite && hasYoutube;
|
||||
var choiceCount = (canSite ? 1 : 0) + (canYoutube ? 1 : 0) + (canBoth ? 1 : 0);
|
||||
// Una sola destinazione disponibile → conferma semplice, senza elenco
|
||||
var multi = !!options.multi && choiceCount > 1;
|
||||
|
||||
if (canBoth) pendingScope = "both";
|
||||
else if (canSite) pendingScope = "site";
|
||||
else if (canYoutube) pendingScope = "youtube";
|
||||
else pendingScope = "both";
|
||||
|
||||
var el = ensureDialog();
|
||||
var titleEl = el.querySelector("#site-confirm-title");
|
||||
var messageEl = el.querySelector("#site-confirm-message");
|
||||
var okBtn = el.querySelector("[data-confirm-ok]");
|
||||
var choicesEl = el.querySelector("#site-confirm-choices");
|
||||
var siteBtn = el.querySelector('[data-confirm-scope="site"]');
|
||||
var youtubeBtn = el.querySelector('[data-confirm-scope="youtube"]');
|
||||
var bothBtn = el.querySelector('[data-confirm-scope="both"]');
|
||||
var hintSite = el.querySelector("[data-hint-site]");
|
||||
var hintYoutube = el.querySelector("[data-hint-youtube]");
|
||||
var hintBoth = el.querySelector("[data-hint-both]");
|
||||
var panel = el.querySelector(".site-confirm__panel");
|
||||
|
||||
if (titleEl) {
|
||||
if (multi) {
|
||||
titleEl.textContent = "Cosa vuoi eliminare?";
|
||||
} else if (pendingScope === "youtube") {
|
||||
titleEl.textContent = "Eliminare da YouTube?";
|
||||
} else if (pendingScope === "site") {
|
||||
titleEl.textContent = "Eliminare dal sito?";
|
||||
} else {
|
||||
titleEl.textContent = /elimin/i.test(message)
|
||||
? "Eliminare definitivamente?"
|
||||
: "Confermi questa operazione?";
|
||||
}
|
||||
}
|
||||
|
||||
if (messageEl) {
|
||||
if (multi) {
|
||||
messageEl.textContent =
|
||||
"Scegli dove rimuovere il replay. L'operazione è irreversibile.";
|
||||
} else if (pendingScope === "youtube") {
|
||||
messageEl.textContent =
|
||||
"Il video verrà rimosso da YouTube. Il replay resterà disponibile sul sito.";
|
||||
} else if (pendingScope === "site") {
|
||||
messageEl.textContent = hasYoutube
|
||||
? "Il replay verrà rimosso dal sito. Il video su YouTube resterà online."
|
||||
: "Stai per eliminare definitivamente questo replay dal sito. L'operazione è irreversibile.";
|
||||
} else {
|
||||
messageEl.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
if (choicesEl) choicesEl.hidden = !multi;
|
||||
if (panel) panel.classList.toggle("site-confirm__panel--choices", multi);
|
||||
|
||||
if (okBtn instanceof HTMLElement) {
|
||||
okBtn.hidden = multi;
|
||||
if (pendingScope === "youtube") okBtn.textContent = "Elimina da YouTube";
|
||||
else if (pendingScope === "site") okBtn.textContent = "Elimina dal sito";
|
||||
else okBtn.textContent = /elimin/i.test(message) ? "Elimina" : "Conferma";
|
||||
}
|
||||
|
||||
setChoiceVisible(siteBtn, canSite);
|
||||
setChoiceVisible(youtubeBtn, canYoutube);
|
||||
setChoiceVisible(bothBtn, canBoth);
|
||||
if (multi) {
|
||||
setChoiceEnabled(siteBtn, canSite);
|
||||
setChoiceEnabled(youtubeBtn, canYoutube);
|
||||
setChoiceEnabled(bothBtn, canBoth);
|
||||
if (hintSite) {
|
||||
hintSite.textContent = canYoutube
|
||||
? "Il video resta su YouTube"
|
||||
: "Rimuove i file dal sito";
|
||||
}
|
||||
if (hintYoutube) {
|
||||
hintYoutube.textContent = canSite
|
||||
? "Resta disponibile sul sito"
|
||||
: "Rimuove solo il video YouTube";
|
||||
}
|
||||
if (hintBoth) hintBoth.textContent = "Eliminazione completa";
|
||||
}
|
||||
|
||||
el.hidden = false;
|
||||
document.body.classList.add("site-confirm-open");
|
||||
|
||||
if (multi) {
|
||||
var focusBtn = canSite ? siteBtn : canYoutube ? youtubeBtn : bothBtn;
|
||||
if (focusBtn instanceof HTMLElement) focusBtn.focus();
|
||||
} else if (okBtn instanceof HTMLElement) {
|
||||
okBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
if (!dialog) return;
|
||||
dialog.hidden = true;
|
||||
document.body.classList.remove("site-confirm-open");
|
||||
pendingForm = null;
|
||||
pendingScope = null;
|
||||
}
|
||||
|
||||
function ensureHiddenInput(form, name, value) {
|
||||
var input = form.querySelector('input[name="' + name + '"]');
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = name;
|
||||
form.appendChild(input);
|
||||
}
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
function submitPending(scope) {
|
||||
var form = pendingForm;
|
||||
if (form && form.closest(".replay-archive")) {
|
||||
saveArchiveScrollPosition();
|
||||
}
|
||||
closeDialog();
|
||||
if (!form) return;
|
||||
ensureHiddenInput(form, "delete_scope", scope || "both");
|
||||
form.dataset.confirmAccepted = "1";
|
||||
if (typeof form.requestSubmit === "function") {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"submit",
|
||||
function (event) {
|
||||
var form = event.target;
|
||||
if (!(form instanceof HTMLFormElement)) return;
|
||||
|
||||
if (form.dataset.confirmAccepted === "1") {
|
||||
delete form.dataset.confirmAccepted;
|
||||
return;
|
||||
}
|
||||
|
||||
var message =
|
||||
form.getAttribute("data-confirm") ||
|
||||
form.getAttribute("data-turbo-confirm");
|
||||
if (!message) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
pendingForm = form;
|
||||
pendingScope = "both";
|
||||
|
||||
var mode = form.getAttribute("data-confirm-mode") || "";
|
||||
var isReplayDelete =
|
||||
mode === "delete-replay" || mode === "delete-replay-youtube";
|
||||
var hasYoutube =
|
||||
form.getAttribute("data-has-youtube") === "1" ||
|
||||
mode === "delete-replay-youtube";
|
||||
var hasSiteAttr = form.getAttribute("data-has-site");
|
||||
var hasSite = hasSiteAttr == null ? true : hasSiteAttr === "1";
|
||||
|
||||
openDialog(message, {
|
||||
multi: isReplayDelete,
|
||||
hasYoutube: hasYoutube,
|
||||
hasSite: hasSite
|
||||
});
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// Dopo submit nell'archivio replay, resta alla stessa altezza di scroll
|
||||
// (il redirect ricarica la pagina e altrimenti torna in cima).
|
||||
var SCROLL_KEY = "matchlivetv.replayArchive.scrollY";
|
||||
|
||||
function saveArchiveScrollPosition() {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
SCROLL_KEY,
|
||||
String(window.scrollY || window.pageYOffset || 0)
|
||||
);
|
||||
} catch (err) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function saveArchiveScroll(event) {
|
||||
var form = event.target;
|
||||
if (!(form instanceof HTMLFormElement)) return;
|
||||
if (!form.closest(".replay-archive")) return;
|
||||
var needsConfirm =
|
||||
form.getAttribute("data-confirm") ||
|
||||
form.getAttribute("data-turbo-confirm");
|
||||
// Con confirm lo scroll si salva in submitPending, dopo la scelta nel dialog
|
||||
if (needsConfirm) return;
|
||||
saveArchiveScrollPosition();
|
||||
}
|
||||
|
||||
function restoreArchiveScroll() {
|
||||
try {
|
||||
var raw = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (raw == null) return;
|
||||
sessionStorage.removeItem(SCROLL_KEY);
|
||||
var top = parseInt(raw, 10);
|
||||
if (isNaN(top)) return;
|
||||
window.scrollTo(0, top);
|
||||
requestAnimationFrame(function () {
|
||||
window.scrollTo(0, top);
|
||||
});
|
||||
} catch (err) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("submit", saveArchiveScroll, true);
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", restoreArchiveScroll);
|
||||
} else {
|
||||
restoreArchiveScroll();
|
||||
}
|
||||
})();
|
||||
@@ -27,7 +27,98 @@ a:hover { text-decoration: underline; }
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.mast-inner .nav-toggle { margin-left: auto; }
|
||||
.mast-tools {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.mast-inner .nav-toggle { margin-left: 0; }
|
||||
.nav-lang {
|
||||
margin-left: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.lang-switcher {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.lang-switcher__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 42px;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3a3a48;
|
||||
background: #1c1c26;
|
||||
color: #f0f0f5;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.lang-switcher__toggle:hover,
|
||||
.lang-switcher.is-open .lang-switcher__toggle {
|
||||
border-color: #6a6a7a;
|
||||
background: #262632;
|
||||
}
|
||||
.lang-switcher__flag {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.lang-switcher__chevron {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid #9a9aaa;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.lang-switcher__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 1400;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 48px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #3a3a48;
|
||||
background: #16161e;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.lang-switcher__menu[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
.lang-switcher__option-form {
|
||||
margin: 0;
|
||||
display: block;
|
||||
}
|
||||
.lang-switcher__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
}
|
||||
.lang-switcher__option:hover {
|
||||
background: #22222e;
|
||||
border-color: #3a3a48;
|
||||
}
|
||||
.lang-switcher__option.is-active {
|
||||
background: rgba(229, 57, 53, 0.15);
|
||||
border-color: rgba(229, 57, 53, 0.4);
|
||||
}
|
||||
.brand { font-weight: 800; font-size: 1.15rem; letter-spacing: 0.03em; color: #fff; flex-shrink: 0; }
|
||||
.brand span { color: #e53935; }
|
||||
.mast-brand {
|
||||
@@ -197,6 +288,12 @@ body.nav-menu-open { overflow: hidden; }
|
||||
font-size: 1.05rem !important;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.nav-lang {
|
||||
margin-left: 0;
|
||||
margin-top: 4px;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
@@ -248,6 +345,10 @@ body.nav-menu-open { overflow: hidden; }
|
||||
.nav-actions {
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav-lang {
|
||||
margin-left: 2px;
|
||||
}
|
||||
.nav-backdrop { display: none !important; }
|
||||
}
|
||||
@@ -466,6 +567,251 @@ body.nav-menu-open { overflow: hidden; }
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.team-public-page { padding-top: 20px; padding-bottom: 48px; }
|
||||
.team-public-hero { margin-bottom: 28px; }
|
||||
.team-public-meta {
|
||||
margin: 0 0 12px;
|
||||
color: #aaa;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.team-public-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.team-public-section { margin-bottom: 32px; }
|
||||
.team-public-more { margin-top: 16px; }
|
||||
.team-public-more a { color: #e53935; text-decoration: none; }
|
||||
.team-public-more a:hover { text-decoration: underline; }
|
||||
.team-public-roster .roster-section { margin-bottom: 16px; }
|
||||
.team-public-empty { margin-top: 24px; }
|
||||
.team-public-empty { margin-top: 24px; }
|
||||
.team-directory { padding-top: 20px; padding-bottom: 48px; }
|
||||
.team-directory-filters { margin: 20px 0 16px; }
|
||||
.team-directory-filters__search { margin-bottom: 16px; }
|
||||
.team-directory-filters__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.team-directory-filters__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.team-directory-filters__field--chips {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.team-directory-filters__label {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: #9a9aaa;
|
||||
line-height: 1.2;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.team-directory-filters__label--spacer {
|
||||
visibility: hidden;
|
||||
}
|
||||
.team-directory-filters__select {
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #34344a;
|
||||
background: #14141c;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.team-directory-filters__select:focus {
|
||||
outline: none;
|
||||
border-color: #e53935;
|
||||
box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.25);
|
||||
}
|
||||
.team-directory-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
.filter-chip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin: 0;
|
||||
}
|
||||
.filter-chip__input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.filter-chip__face {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #34344a;
|
||||
background: #14141c;
|
||||
color: #b8b8c8;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.filter-chip:hover .filter-chip__face {
|
||||
border-color: #4a4a62;
|
||||
color: #eee;
|
||||
}
|
||||
.filter-chip__input:focus-visible + .filter-chip__face {
|
||||
outline: 2px solid rgba(229, 57, 53, 0.55);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.filter-chip.is-active .filter-chip__face,
|
||||
.filter-chip__input:checked + .filter-chip__face {
|
||||
border-color: rgba(229, 57, 53, 0.75);
|
||||
background: rgba(229, 57, 53, 0.14);
|
||||
color: #fff;
|
||||
box-shadow: inset 0 0 0 1px rgba(229, 57, 53, 0.2);
|
||||
}
|
||||
.filter-chip__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-chip__dot--live {
|
||||
background: #e53935;
|
||||
box-shadow: 0 0 8px rgba(229, 57, 53, 0.7);
|
||||
}
|
||||
.filter-chip.is-active .filter-chip__dot--live,
|
||||
.filter-chip__input:checked + .filter-chip__face .filter-chip__dot--live {
|
||||
animation: filter-chip-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.filter-chip__icon {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.85;
|
||||
line-height: 1;
|
||||
}
|
||||
@keyframes filter-chip-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.55; transform: scale(0.85); }
|
||||
}
|
||||
.team-directory-filters__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.team-directory-filters__reset {
|
||||
height: 44px;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
.team-directory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.team-directory-card {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: #16161e;
|
||||
border: 1px solid #2a2a36;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.team-directory-card:hover {
|
||||
border-color: #e53935;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.team-directory-card__media { flex-shrink: 0; }
|
||||
.team-directory-card__photo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.team-directory-card__photo--logo {
|
||||
object-fit: contain;
|
||||
background: #111;
|
||||
padding: 8px;
|
||||
}
|
||||
.team-directory-card__photo--fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1f1f28;
|
||||
color: #e53935;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.team-directory-card__body { min-width: 0; }
|
||||
.team-directory-card__club {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.78rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.team-directory-card__name {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.05rem;
|
||||
color: #fff;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.team-directory-card__meta {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
}
|
||||
.team-directory-card__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.team-directory-footer {
|
||||
margin-top: 28px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.team-directory-footer a { color: #e53935; text-decoration: none; }
|
||||
.team-directory-footer a:hover { text-decoration: underline; }
|
||||
.roster-hero__photo--logo { object-fit: contain; background: #111; padding: 16px; }
|
||||
.team-public-logo-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--club-primary, #e53935);
|
||||
}
|
||||
.live-card__team-link {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(229, 57, 53, 0.45);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.live-card__team-link:hover { color: #fff; text-decoration-color: #e53935; }
|
||||
.team-streaming-staff {
|
||||
margin-top: 36px;
|
||||
padding-top: 28px;
|
||||
@@ -1395,11 +1741,11 @@ body.cookie-banner-visible {
|
||||
}
|
||||
|
||||
.replay-archive-table__col-status {
|
||||
width: 18%;
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.replay-archive-table__col-actions {
|
||||
width: 12%;
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
.replay-archive__detail-line {
|
||||
@@ -1418,7 +1764,7 @@ body.cookie-banner-visible {
|
||||
}
|
||||
|
||||
.replay-archive-table__col-status .replay-archive__status {
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.replay-archive__thumb {
|
||||
@@ -1483,7 +1829,13 @@ a.replay-archive__thumb:hover {
|
||||
.replay-archive__privacy-form,
|
||||
.replay-archive__action-form {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.replay-archive__title-form {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.replay-archive__title-input {
|
||||
@@ -1493,11 +1845,50 @@ a.replay-archive__thumb:hover {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.replay-archive__privacy-select {
|
||||
width: 100%;
|
||||
.replay-archive__privacy-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3a3a48;
|
||||
background: #1c1c26;
|
||||
color: #c8c8d4;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.replay-archive__privacy-toggle:hover {
|
||||
border-color: #6a6a7a;
|
||||
background: #262632;
|
||||
color: #fff;
|
||||
}
|
||||
.replay-archive__privacy-toggle--public {
|
||||
color: #81c784;
|
||||
border-color: rgba(129, 199, 132, 0.35);
|
||||
background: rgba(76, 175, 80, 0.12);
|
||||
}
|
||||
.replay-archive__privacy-toggle--public:hover {
|
||||
border-color: #81c784;
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #a5d6a7;
|
||||
}
|
||||
.replay-archive__privacy-toggle--unlisted {
|
||||
color: #ffca28;
|
||||
border-color: rgba(255, 202, 40, 0.35);
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
.replay-archive__privacy-toggle--unlisted:hover {
|
||||
border-color: #ffca28;
|
||||
background: rgba(255, 193, 7, 0.18);
|
||||
color: #ffe082;
|
||||
}
|
||||
.replay-archive__privacy-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.replay-archive__status {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Public team page", type: :request do
|
||||
let(:club) { Club.create!(name: "ASD Eagles", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
let!(:team) do
|
||||
club.teams.create!(
|
||||
name: "Tigers Volley U17",
|
||||
sport_key: "pallavolo",
|
||||
description: "Squadra giovanile under 17.",
|
||||
roster_public: true
|
||||
)
|
||||
end
|
||||
|
||||
it "è accessibile senza login" do
|
||||
get public_team_page_path(team.slug)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Tigers Volley U17")
|
||||
expect(response.body).to include("ASD Eagles")
|
||||
expect(response.body).to include("Squadra giovanile under 17")
|
||||
end
|
||||
|
||||
it "restituisce 404 per slug inesistente" do
|
||||
get public_team_page_path("squadra-inesistente")
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it "mostra l'organico solo se roster_public è attivo" do
|
||||
team.roster_members.create!(full_name: "Mario Rossi", category: "player", jersey_number: 7)
|
||||
|
||||
get public_team_page_path(team.slug)
|
||||
expect(response.body).to include("Mario Rossi")
|
||||
|
||||
team.update!(roster_public: false)
|
||||
|
||||
get public_team_page_path(team.slug)
|
||||
expect(response.body).not_to include("Mario Rossi")
|
||||
expect(response.body).not_to include("Organico")
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.describe "Public team directory", type: :request do
|
||||
let(:club) { Club.create!(name: "ASD Eagles", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
let(:user) { User.create!(email: "dir2@test.com", name: "Coach", password: "password123", role: "coach") }
|
||||
let!(:team) { club.teams.create!(name: "Tigers Volley U17", sport_key: "pallavolo") }
|
||||
|
||||
before do
|
||||
club.club_memberships.create!(user: user, role: "owner")
|
||||
match = team.matches.create!(opponent_name: "Rival", sport_key: "pallavolo", scheduled_at: 1.day.from_now)
|
||||
StreamSession.create!(
|
||||
match: match, user: user, status: "live", platform: "matchlivetv",
|
||||
privacy_status: "public", publish_token: "tok-dir", started_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
it "elenca le squadre attive senza login" do
|
||||
get public_team_pages_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Tigers Volley U17")
|
||||
expect(response.body).to include("In diretta")
|
||||
end
|
||||
|
||||
it "filtra per sport via query string" do
|
||||
get public_team_pages_path(sport: "basket")
|
||||
|
||||
expect(response.body).not_to include("Tigers Volley U17")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe LocaleResolver do
|
||||
describe ".normalize" do
|
||||
it "accetta lingue supportate" do
|
||||
expect(described_class.normalize("en")).to eq(:en)
|
||||
expect(described_class.normalize("fr-FR")).to eq(:fr)
|
||||
expect(described_class.normalize("de_DE")).to eq(:de)
|
||||
end
|
||||
|
||||
it "rifiuta lingue non supportate" do
|
||||
expect(described_class.normalize("pt")).to be_nil
|
||||
expect(described_class.normalize("")).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe ".from_accept_language" do
|
||||
it "prende la prima lingua supportata" do
|
||||
expect(described_class.from_accept_language("fr-FR,fr;q=0.9,en;q=0.8")).to eq(:fr)
|
||||
expect(described_class.from_accept_language("pt-BR,en-US;q=0.8")).to eq(:en)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,15 +16,21 @@ RSpec.describe Recordings::Delete do
|
||||
privacy_status: "public",
|
||||
expires_at: 10.days.from_now,
|
||||
storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4",
|
||||
youtube_video_id: "abc123xyz"
|
||||
youtube_video_id: "abc123xyz",
|
||||
youtube_published_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
it "elimina storage e revoca youtube_video_id" do
|
||||
def stub_delete_deps
|
||||
storage = instance_double(Recordings::Storage, delete: true)
|
||||
allow(Recordings::Storage).to receive(:new).and_return(storage)
|
||||
yt = instance_double(Youtube::VideoLifecycleService, delete!: true)
|
||||
allow(Youtube::VideoLifecycleService).to receive(:new).with(recording).and_return(yt)
|
||||
[storage, yt]
|
||||
end
|
||||
|
||||
it "elimina storage e YouTube (scope both)" do
|
||||
storage, yt = stub_delete_deps
|
||||
|
||||
described_class.new(recording).call
|
||||
|
||||
@@ -35,4 +41,32 @@ RSpec.describe Recordings::Delete do
|
||||
expect(recording.youtube_video_id).to be_nil
|
||||
expect(recording.storage_key).to be_nil
|
||||
end
|
||||
|
||||
it "elimina solo dal sito lasciando YouTube" do
|
||||
storage, yt = stub_delete_deps
|
||||
|
||||
described_class.new(recording, scope: :site).call
|
||||
|
||||
expect(yt).not_to have_received(:delete!)
|
||||
expect(storage).to have_received(:delete).at_least(:once)
|
||||
recording.reload
|
||||
expect(recording.deleted_at).to be_present
|
||||
expect(recording.storage_key).to be_nil
|
||||
expect(recording.youtube_video_id).to eq("abc123xyz")
|
||||
end
|
||||
|
||||
it "elimina solo da YouTube lasciando il replay sul sito" do
|
||||
storage, yt = stub_delete_deps
|
||||
|
||||
described_class.new(recording, scope: :youtube).call
|
||||
|
||||
expect(yt).to have_received(:delete!)
|
||||
expect(storage).not_to have_received(:delete)
|
||||
recording.reload
|
||||
expect(recording.deleted_at).to be_nil
|
||||
expect(recording.status).to eq("ready")
|
||||
expect(recording.storage_key).to be_present
|
||||
expect(recording.youtube_video_id).to be_nil
|
||||
expect(recording.youtube_published_at).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Teams::GenerateSlug do
|
||||
let(:club) { Club.create!(name: "Test Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
|
||||
it "genera uno slug dal nome" do
|
||||
team = club.teams.build(name: "Crazy Volley U17B", sport_key: "pallavolo")
|
||||
expect(described_class.call(team)).to eq("crazy-volley-u17b")
|
||||
end
|
||||
|
||||
it "aggiunge un suffisso se lo slug esiste già" do
|
||||
club.teams.create!(name: "Tigers", sport_key: "pallavolo")
|
||||
team = club.teams.build(name: "Tigers", sport_key: "pallavolo")
|
||||
expect(described_class.call(team)).to eq("tigers-2")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Teams::PublicDirectory do
|
||||
let(:user) { User.create!(email: "dir-#{SecureRandom.hex(4)}@test.com", name: "Dir", password: "password123", role: "coach") }
|
||||
let(:club) { Club.create!(name: "ASD Directory #{SecureRandom.hex(3)}", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
let!(:team_live) { club.teams.create!(name: "Live Team", sport_key: "pallavolo") }
|
||||
let!(:team_replay) { club.teams.create!(name: "Replay Team", sport_key: "basket") }
|
||||
let!(:team_upcoming) { club.teams.create!(name: "Future Team", sport_key: "pallavolo") }
|
||||
let!(:team_empty) { club.teams.create!(name: "Empty Team", sport_key: "pallavolo") }
|
||||
|
||||
before do
|
||||
club.club_memberships.create!(user: user, role: "owner")
|
||||
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
||||
|
||||
live_match = team_live.matches.create!(opponent_name: "Rival A", sport_key: "pallavolo", scheduled_at: 1.hour.from_now)
|
||||
StreamSession.create!(
|
||||
match: live_match, user: user, status: "live", platform: "matchlivetv",
|
||||
privacy_status: "public", publish_token: "tok-live", started_at: Time.current
|
||||
)
|
||||
|
||||
replay_match = team_replay.matches.create!(opponent_name: "Rival B", sport_key: "basket")
|
||||
replay_session = StreamSession.create!(
|
||||
match: replay_match, user: user, status: "ended", platform: "matchlivetv",
|
||||
privacy_status: "public", publish_token: "tok-replay", started_at: 2.days.ago, ended_at: 1.day.ago
|
||||
)
|
||||
team_replay.recordings.create!(
|
||||
stream_session: replay_session,
|
||||
team: team_replay,
|
||||
status: "ready",
|
||||
privacy_status: "public",
|
||||
storage_backend: "local",
|
||||
storage_key: "teams/#{team_replay.id}/test.mp4",
|
||||
recorded_at: 1.day.ago
|
||||
)
|
||||
|
||||
team_upcoming.matches.create!(
|
||||
opponent_name: "Rival C",
|
||||
sport_key: "pallavolo",
|
||||
scheduled_at: 2.days.from_now,
|
||||
location: "VenueUnique42"
|
||||
)
|
||||
end
|
||||
|
||||
it "include solo squadre con presenza pubblica" do
|
||||
ids = club_entries.map { |e| e.team.id }
|
||||
|
||||
expect(ids).to include(team_live.id, team_replay.id, team_upcoming.id)
|
||||
expect(ids).not_to include(team_empty.id)
|
||||
end
|
||||
|
||||
it "filtra per sport" do
|
||||
ids = club_entries(sport: "basket").map { |e| e.team.id }
|
||||
|
||||
expect(ids).to contain_exactly(team_replay.id)
|
||||
end
|
||||
|
||||
it "filtra per testo libero" do
|
||||
ids = club_entries(q: "VenueUnique42").map { |e| e.team.id }
|
||||
|
||||
expect(ids).to contain_exactly(team_upcoming.id)
|
||||
end
|
||||
|
||||
it "filtra per diretta in corso" do
|
||||
ids = club_entries(live: true).map { |e| e.team.id }
|
||||
|
||||
expect(ids).to contain_exactly(team_live.id)
|
||||
end
|
||||
|
||||
it "filtra per replay" do
|
||||
ids = club_entries(replays: true).map { |e| e.team.id }
|
||||
|
||||
expect(ids).to contain_exactly(team_replay.id)
|
||||
end
|
||||
|
||||
it "ordina le dirette per prime" do
|
||||
slugs = club_entries.map { |e| e.team.slug }
|
||||
|
||||
expect(slugs.first).to eq(team_live.slug)
|
||||
end
|
||||
|
||||
def club_entries(**params)
|
||||
described_class.call(**params).select { |e| e.team.club_id == club.id }
|
||||
end
|
||||
end
|
||||
@@ -14,14 +14,14 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
android {
|
||||
namespace = "com.matchlivetv.match_live_tv"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.matchlivetv.match_live_tv"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 21
|
||||
versionName = "2.0.0-native"
|
||||
targetSdk = 36
|
||||
versionCode = 23
|
||||
versionName = "2.0.2-native"
|
||||
|
||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||
?: "https://www.matchlivetv.it"
|
||||
@@ -75,6 +75,7 @@ dependencies {
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MatchLiveTv"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
@@ -29,7 +30,7 @@
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
@@ -57,5 +58,15 @@
|
||||
android:name=".streaming.LiveBroadcastService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="camera|microphone" />
|
||||
|
||||
<!-- Persistenza lingue AppCompat (API < 33) -->
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.matchlivetv.match_live_tv
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.key
|
||||
import com.matchlivetv.match_live_tv.core.AppLocale
|
||||
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
|
||||
|
||||
@@ -12,6 +15,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private val container by lazy { (application as MatchLiveTvApplication).container }
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
super.attachBaseContext(AppLocale.wrap(newBase))
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
container.broadcastCoordinator.pauseForConfigurationChange()
|
||||
super.onConfigurationChanged(newConfig)
|
||||
@@ -21,9 +28,12 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val localeTag = AppLocale.currentTag(this)
|
||||
setContent {
|
||||
MatchLiveTheme {
|
||||
AppNavHost(container)
|
||||
key(localeTag) {
|
||||
MatchLiveTheme {
|
||||
AppNavHost(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.matchlivetv.match_live_tv.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.res.Configuration
|
||||
import android.os.Handler
|
||||
import android.os.LocaleList
|
||||
import android.os.Looper
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Lingua app persistita in SharedPreferences + context wrapping.
|
||||
* Più affidabile di soli AppCompat locales su ComponentActivity/Compose.
|
||||
*/
|
||||
object AppLocale {
|
||||
const val SYSTEM = ""
|
||||
|
||||
private const val PREFS = "mltv_locale"
|
||||
private const val KEY_TAG = "tag"
|
||||
|
||||
data class Option(val tag: String, val labelRes: Int)
|
||||
|
||||
val options = listOf(
|
||||
Option(SYSTEM, com.matchlivetv.match_live_tv.R.string.language_system),
|
||||
Option("it", com.matchlivetv.match_live_tv.R.string.language_italian),
|
||||
Option("en", com.matchlivetv.match_live_tv.R.string.language_english),
|
||||
Option("fr", com.matchlivetv.match_live_tv.R.string.language_french),
|
||||
Option("de", com.matchlivetv.match_live_tv.R.string.language_german),
|
||||
Option("es", com.matchlivetv.match_live_tv.R.string.language_spanish),
|
||||
)
|
||||
|
||||
fun normalize(tag: String): String {
|
||||
if (tag.isBlank()) return SYSTEM
|
||||
return tag.substringBefore(",").substringBefore("-").substringBefore("_").lowercase()
|
||||
}
|
||||
|
||||
fun currentTag(context: Context): String {
|
||||
val stored = context.applicationContext
|
||||
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_TAG, SYSTEM)
|
||||
.orEmpty()
|
||||
return normalize(stored)
|
||||
}
|
||||
|
||||
fun wrap(context: Context): Context {
|
||||
val tag = currentTag(context)
|
||||
if (tag.isBlank()) return context
|
||||
|
||||
val locale = Locale.forLanguageTag(tag)
|
||||
Locale.setDefault(locale)
|
||||
val config = Configuration(context.resources.configuration)
|
||||
config.setLocales(LocaleList(locale))
|
||||
return context.createConfigurationContext(config)
|
||||
}
|
||||
|
||||
fun apply(activity: Activity, tag: String) {
|
||||
val normalized = normalize(tag)
|
||||
val prefs = activity.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
if (normalized == currentTag(activity)) return
|
||||
|
||||
// commit sincronizzato: deve essere scritto prima del recreate
|
||||
prefs.edit().putString(KEY_TAG, normalized).commit()
|
||||
|
||||
val locales = if (normalized.isBlank()) {
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
} else {
|
||||
LocaleListCompat.forLanguageTags(normalized)
|
||||
}
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
activity.recreate()
|
||||
}
|
||||
|
||||
fun applyAfterDismiss(activity: Activity?, tag: String, dismiss: () -> Unit) {
|
||||
dismiss()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (activity == null || activity.isFinishing || activity.isDestroyed) return@post
|
||||
apply(activity, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.matchlivetv.match_live_tv.core
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Normalizza l'URL RTMP restituito dall'API per il dispositivo corrente.
|
||||
* In locale Docker espone spesso 127.0.0.1 / mediamtx: dall'emulatore Android
|
||||
* vanno riscritti sull'host dell'API (es. 10.0.2.2).
|
||||
*/
|
||||
object RtmpIngestUrl {
|
||||
private val rewriteHosts = setOf(
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"mediamtx",
|
||||
"host.docker.internal",
|
||||
)
|
||||
|
||||
fun resolveForDevice(urlString: String): String =
|
||||
resolve(urlString, apiBaseUrl = AppConfig.apiBaseUrl)
|
||||
|
||||
fun resolve(urlString: String, apiBaseUrl: String): String {
|
||||
if (urlString.isBlank()) return urlString
|
||||
val uri = runCatching { URI(urlString) }.getOrNull() ?: return urlString
|
||||
val host = uri.host?.lowercase() ?: return urlString
|
||||
if (host !in rewriteHosts) return urlString
|
||||
|
||||
val apiHost = runCatching { URI(apiBaseUrl).host }.getOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return urlString
|
||||
|
||||
val port = if (uri.port > 0) uri.port else 1935
|
||||
val path = uri.rawPath.orEmpty()
|
||||
val query = uri.rawQuery
|
||||
|
||||
return buildString {
|
||||
append("rtmp://")
|
||||
append(apiHost)
|
||||
append(':')
|
||||
append(port)
|
||||
append(path)
|
||||
if (!query.isNullOrBlank()) {
|
||||
append('?')
|
||||
append(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -32,6 +32,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.matchlivetv.match_live_tv.core.DeviceTelemetry
|
||||
import com.matchlivetv.match_live_tv.core.parseColorHex
|
||||
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
|
||||
import com.matchlivetv.match_live_tv.core.RtmpIngestUrl
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.domain.Match
|
||||
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
|
||||
@@ -85,7 +86,10 @@ fun BroadcastScreen(
|
||||
}
|
||||
|
||||
fun broadcastConfig(loaded: StreamSession): BroadcastConfig =
|
||||
StreamVideoPreset.broadcastConfig(loaded, loaded.rtmpIngestUrl.orEmpty())
|
||||
StreamVideoPreset.broadcastConfig(
|
||||
loaded,
|
||||
RtmpIngestUrl.resolveForDevice(loaded.rtmpIngestUrl.orEmpty()),
|
||||
)
|
||||
|
||||
suspend fun stopStreamPermanently() {
|
||||
runCatching { container.sessionRepository.stopSession(sessionId) }
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.matchlivetv.match_live_tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.core.AppLocale
|
||||
import com.matchlivetv.match_live_tv.core.findActivity
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||
|
||||
@Composable
|
||||
fun LanguagePickerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context.findActivity()
|
||||
val selected = AppLocale.currentTag(context)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.language_label)) },
|
||||
text = {
|
||||
Column {
|
||||
AppLocale.options.forEach { option ->
|
||||
val optionTag = AppLocale.normalize(option.tag)
|
||||
val isSelected = selected == optionTag
|
||||
fun choose() {
|
||||
AppLocale.applyAfterDismiss(activity, option.tag, onDismiss)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = ::choose)
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = ::choose,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(option.labelRes),
|
||||
color = MatchColors.TextSecondary,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+23
-15
@@ -17,6 +17,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -27,13 +28,16 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||
@@ -50,8 +54,12 @@ fun LoginScreen(
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var showLanguagePicker by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val errCredentials = stringResource(R.string.login_error_credentials)
|
||||
val errUnreachable = stringResource(R.string.login_error_unreachable)
|
||||
val errGeneric = stringResource(R.string.login_error_generic)
|
||||
|
||||
fun submitLogin() {
|
||||
if (loading || email.isBlank() || password.isBlank()) return
|
||||
@@ -64,10 +72,9 @@ fun LoginScreen(
|
||||
onLoggedIn()
|
||||
}.onFailure {
|
||||
error = when {
|
||||
it.message?.contains("401") == true -> "Email o password non corretti"
|
||||
it.message?.contains("timeout", ignoreCase = true) == true ->
|
||||
"Server non raggiungibile. Verifica la connessione."
|
||||
else -> it.message ?: "Login fallito"
|
||||
it.message?.contains("401") == true -> errCredentials
|
||||
it.message?.contains("timeout", ignoreCase = true) == true -> errUnreachable
|
||||
else -> it.message ?: errGeneric
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
@@ -75,6 +82,9 @@ fun LoginScreen(
|
||||
}
|
||||
|
||||
MatchScreenScaffold {
|
||||
if (showLanguagePicker) {
|
||||
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
@@ -82,22 +92,20 @@ fun LoginScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
MatchLiveWordmark(showSlogan = true)
|
||||
Spacer(Modifier.height(48.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
TextButton(onClick = { showLanguagePicker = true }) {
|
||||
Text(stringResource(R.string.language_label), color = MatchColors.TextSecondary)
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
text = "ACCEDI",
|
||||
text = stringResource(R.string.login_submit).uppercase(),
|
||||
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Gestisci le dirette della tua squadra",
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("Email") },
|
||||
label = { Text(stringResource(R.string.login_email)) },
|
||||
placeholder = { Text("coach@squadra.it") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
@@ -114,7 +122,7 @@ fun LoginScreen(
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
label = { Text(stringResource(R.string.login_password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) {
|
||||
@@ -153,7 +161,7 @@ fun LoginScreen(
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
MatchPrimaryButton(
|
||||
label = "ACCEDI",
|
||||
label = stringResource(R.string.login_submit).uppercase(),
|
||||
loading = loading,
|
||||
enabled = email.isNotBlank() && password.isNotBlank(),
|
||||
onClick = { submitLogin() },
|
||||
|
||||
+69
-6
@@ -2,24 +2,30 @@ package com.matchlivetv.match_live_tv.ui.matches
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarToday
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.PlayCircleOutline
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
@@ -41,10 +47,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.core.isScheduledFuture
|
||||
import com.matchlivetv.match_live_tv.core.parseApiInstant
|
||||
import com.matchlivetv.match_live_tv.domain.Match
|
||||
@@ -473,26 +484,78 @@ fun TeamPickerBar(
|
||||
team: Team,
|
||||
showPicker: Boolean,
|
||||
onClick: () -> Unit,
|
||||
expandable: Boolean = true,
|
||||
) {
|
||||
if (!showPicker) return
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MatchColors.Surface, RoundedCornerShape(12.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
.clip(shape)
|
||||
.background(MatchColors.SurfaceElevated, shape)
|
||||
.border(
|
||||
BorderStroke(
|
||||
width = if (expandable) 1.5.dp else 1.dp,
|
||||
color = if (expandable) MatchColors.PrimaryRed.copy(alpha = 0.55f) else MatchColors.Outline,
|
||||
),
|
||||
shape,
|
||||
)
|
||||
.then(if (expandable) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 14.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.Groups, null, tint = MatchColors.PrimaryRed)
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.background(MatchColors.PrimaryRed.copy(alpha = 0.16f), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Groups,
|
||||
contentDescription = null,
|
||||
tint = MatchColors.PrimaryRed,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Squadra per la diretta", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
if (expandable) {
|
||||
stringResource(R.string.matches_tap_change_team)
|
||||
} else {
|
||||
stringResource(R.string.matches_team_for_live)
|
||||
},
|
||||
color = if (expandable) MatchColors.PrimaryRed else MatchColors.TextSecondary,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(team.name, style = MaterialTheme.typography.titleMedium)
|
||||
team.clubName?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Default.ChevronRight, null, tint = MatchColors.TextSecondary)
|
||||
if (expandable) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.background(MatchColors.PrimaryRed.copy(alpha = 0.14f), RoundedCornerShape(999.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.matches_change),
|
||||
color = MatchColors.PrimaryRed,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.ExpandMore,
|
||||
contentDescription = stringResource(R.string.matches_change),
|
||||
tint = MatchColors.PrimaryRed,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+58
-21
@@ -17,7 +17,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -41,14 +43,17 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.core.isScheduledFuture
|
||||
import com.matchlivetv.match_live_tv.core.parseApiInstant
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher
|
||||
import com.matchlivetv.match_live_tv.domain.Match
|
||||
import com.matchlivetv.match_live_tv.domain.Team
|
||||
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||
@@ -82,6 +87,7 @@ fun MatchesScreen(
|
||||
var resumeMatch by remember { mutableStateOf<Match?>(null) }
|
||||
var configureMatch by remember { mutableStateOf<Match?>(null) }
|
||||
var deleteMatch by remember { mutableStateOf<Match?>(null) }
|
||||
var showLanguagePicker by remember { mutableStateOf(false) }
|
||||
|
||||
suspend fun showMessage(message: String) {
|
||||
snackbarHostState.showSnackbar(message)
|
||||
@@ -159,18 +165,32 @@ fun MatchesScreen(
|
||||
titleContentColor = Color.White,
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = { showLanguagePicker = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Language,
|
||||
contentDescription = stringResource(R.string.language_label),
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
container.authRepository.logout()
|
||||
onLogout()
|
||||
}
|
||||
}) {
|
||||
Text("Esci", color = MatchColors.TextSecondary)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = stringResource(R.string.action_logout),
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
if (showLanguagePicker) {
|
||||
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing,
|
||||
@@ -192,7 +212,10 @@ fun MatchesScreen(
|
||||
) {
|
||||
Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
MatchPrimaryButton(label = "RIPROVA", onClick = { reload(showInitialSpinner = false) })
|
||||
MatchPrimaryButton(
|
||||
label = stringResource(R.string.matches_retry).uppercase(),
|
||||
onClick = { reload(showInitialSpinner = false) },
|
||||
)
|
||||
}
|
||||
else -> LazyColumn(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
@@ -200,23 +223,26 @@ fun MatchesScreen(
|
||||
item {
|
||||
Column(Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
"Ciao, ${session?.user?.name.orEmpty()}",
|
||||
stringResource(
|
||||
R.string.matches_hello,
|
||||
session?.user?.name.orEmpty(),
|
||||
),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Riprendi una diretta in corso o avvia una partita programmata.",
|
||||
stringResource(R.string.matches_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
MatchSecondaryButton(
|
||||
label = "PARTITA PROGRAMMATA",
|
||||
label = stringResource(R.string.matches_schedule).uppercase(),
|
||||
onClick = { showScheduleSheet = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
MatchPrimaryButton(
|
||||
label = "NUOVA PARTITA",
|
||||
label = stringResource(R.string.matches_new).uppercase(),
|
||||
onClick = { showNewMatchSheet = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -226,6 +252,7 @@ fun MatchesScreen(
|
||||
TeamPickerBar(
|
||||
team = team,
|
||||
showPicker = true,
|
||||
expandable = teams.size > 1,
|
||||
onClick = { if (teams.size > 1) showTeamSheet = true },
|
||||
)
|
||||
}
|
||||
@@ -238,9 +265,11 @@ fun MatchesScreen(
|
||||
item {
|
||||
Text(
|
||||
when {
|
||||
calendarMatches.isEmpty() && activeMatch == null -> "Nessuna partita in calendario"
|
||||
scheduledMatches.isNotEmpty() -> "Partite programmate"
|
||||
else -> "Pronte da avviare"
|
||||
calendarMatches.isEmpty() && activeMatch == null ->
|
||||
stringResource(R.string.matches_empty_title)
|
||||
scheduledMatches.isNotEmpty() ->
|
||||
stringResource(R.string.matches_scheduled_title)
|
||||
else -> stringResource(R.string.matches_ready_title)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MatchColors.TextSecondary,
|
||||
@@ -249,16 +278,25 @@ fun MatchesScreen(
|
||||
}
|
||||
if (calendarMatches.isEmpty() && activeMatch == null) {
|
||||
item {
|
||||
val emptyHint = stringResource(R.string.matches_empty_hint)
|
||||
val activeTeamLine = activeTeam?.name?.let {
|
||||
stringResource(R.string.matches_active_team, it)
|
||||
}
|
||||
val multiTeamHint = if (teams.size > 1) {
|
||||
stringResource(R.string.matches_multi_team_hint)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Text(
|
||||
buildString {
|
||||
append("Programma una partita o avviane una nuova con «Nuova partita».")
|
||||
activeTeam?.name?.let { teamName ->
|
||||
append("\n\nSquadra attiva: ")
|
||||
append(teamName)
|
||||
append('.')
|
||||
append(emptyHint)
|
||||
activeTeamLine?.let {
|
||||
append("\n\n")
|
||||
append(it)
|
||||
}
|
||||
if (teams.size > 1) {
|
||||
append("\nHai più squadre: verifica quella selezionata sopra.")
|
||||
multiTeamHint?.let {
|
||||
append("\n")
|
||||
append(it)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -269,7 +307,7 @@ fun MatchesScreen(
|
||||
} else if (calendarMatches.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Nessuna altra partita in calendario.",
|
||||
stringResource(R.string.matches_no_other),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
@@ -437,19 +475,18 @@ private fun NoTeamContent(onRetry: () -> Unit) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"Nessuna squadra assegnata",
|
||||
stringResource(R.string.matches_no_team_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Per trasmettere devi essere responsabile della trasmissione di almeno una squadra. " +
|
||||
"Chiedi al tuo club di aggiungerti come staff trasmissione.",
|
||||
stringResource(R.string.matches_no_team_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
MatchPrimaryButton(label = "RIPROVA", onClick = onRetry)
|
||||
MatchPrimaryButton(label = stringResource(R.string.matches_retry).uppercase(), onClick = onRetry)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Match Live TV</string>
|
||||
<string name="app_slogan">Jedes Spiel, jedes Event, für eure Fans.</string>
|
||||
<string name="language_label">Sprache</string>
|
||||
<string name="language_system">System</string>
|
||||
<string name="language_italian">Italiano</string>
|
||||
<string name="language_english">English</string>
|
||||
<string name="language_french">Français</string>
|
||||
<string name="language_german">Deutsch</string>
|
||||
<string name="language_spanish">Español</string>
|
||||
<string name="action_logout">Abmelden</string>
|
||||
<string name="action_login">Anmelden</string>
|
||||
<string name="action_cancel">Abbrechen</string>
|
||||
<string name="action_save">Speichern</string>
|
||||
<string name="login_email">E-Mail</string>
|
||||
<string name="login_password">Passwort</string>
|
||||
<string name="login_submit">Anmelden</string>
|
||||
<string name="login_error_credentials">Falsche E-Mail oder Passwort</string>
|
||||
<string name="login_error_unreachable">Server nicht erreichbar. Verbindung prüfen.</string>
|
||||
<string name="login_error_generic">Anmeldung fehlgeschlagen</string>
|
||||
<string name="matches_title">Spiele</string>
|
||||
<string name="matches_hello">Hallo, %1$s</string>
|
||||
<string name="matches_subtitle">Nimm einen laufenden Livestream wieder auf oder starte ein geplantes Spiel.</string>
|
||||
<string name="matches_schedule">Geplantes Spiel</string>
|
||||
<string name="matches_new">Neues Spiel</string>
|
||||
<string name="matches_empty_title">Keine Spiele im Kalender</string>
|
||||
<string name="matches_scheduled_title">Geplante Spiele</string>
|
||||
<string name="matches_ready_title">Bereit zum Start</string>
|
||||
<string name="matches_empty_hint">Plane ein Spiel oder starte ein neues mit «Neues Spiel».</string>
|
||||
<string name="matches_active_team">Aktives Team: %1$s.</string>
|
||||
<string name="matches_multi_team_hint">Du hast mehrere Teams: prüfe die oben ausgewählte.</string>
|
||||
<string name="matches_no_other">Keine weiteren Spiele im Kalender.</string>
|
||||
<string name="matches_retry">Erneut versuchen</string>
|
||||
<string name="matches_load_error">Ladefehler</string>
|
||||
<string name="matches_no_team_title">Kein Team zugewiesen</string>
|
||||
<string name="matches_no_team_body">Zum Streamen musst du Übertragungs-Staff für mindestens ein Team sein. Bitte deinen Verein, dich als Streaming-Staff hinzuzufügen.</string>
|
||||
<string name="matches_tap_change_team">Tippen zum Teamwechsel</string>
|
||||
<string name="matches_team_for_live">Team für den Livestream</string>
|
||||
<string name="matches_change">Wechseln</string>
|
||||
<string name="streaming_notification_channel">Livestream</string>
|
||||
<string name="streaming_notification_channel_desc">Benachrichtigung während des Livestreams</string>
|
||||
<string name="streaming_notification_title">Match Live TV</string>
|
||||
<string name="streaming_notification_active">Stream wird vorbereitet…</string>
|
||||
<string name="streaming_notification_connecting">RTMP-Verbindung…</string>
|
||||
<string name="streaming_notification_streaming">Live</string>
|
||||
<string name="streaming_notification_reconnecting">Erneute Verbindung…</string>
|
||||
<string name="streaming_notification_error">Streaming-Fehler</string>
|
||||
<string name="streaming_notification_stop">Beenden</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Match Live TV</string>
|
||||
<string name="app_slogan">Every match, every event, for your fans.</string>
|
||||
<string name="language_label">Language</string>
|
||||
<string name="language_system">System</string>
|
||||
<string name="language_italian">Italiano</string>
|
||||
<string name="language_english">English</string>
|
||||
<string name="language_french">Français</string>
|
||||
<string name="language_german">Deutsch</string>
|
||||
<string name="language_spanish">Español</string>
|
||||
<string name="action_logout">Log out</string>
|
||||
<string name="action_login">Log in</string>
|
||||
<string name="action_cancel">Cancel</string>
|
||||
<string name="action_save">Save</string>
|
||||
<string name="login_email">Email</string>
|
||||
<string name="login_password">Password</string>
|
||||
<string name="login_submit">Log in</string>
|
||||
<string name="login_error_credentials">Incorrect email or password</string>
|
||||
<string name="login_error_unreachable">Server unreachable. Check your connection.</string>
|
||||
<string name="login_error_generic">Login failed</string>
|
||||
<string name="matches_title">Matches</string>
|
||||
<string name="matches_hello">Hi, %1$s</string>
|
||||
<string name="matches_subtitle">Resume a live stream or start a scheduled match.</string>
|
||||
<string name="matches_schedule">Scheduled match</string>
|
||||
<string name="matches_new">New match</string>
|
||||
<string name="matches_empty_title">No matches on the calendar</string>
|
||||
<string name="matches_scheduled_title">Scheduled matches</string>
|
||||
<string name="matches_ready_title">Ready to start</string>
|
||||
<string name="matches_empty_hint">Schedule a match or start a new one with «New match».</string>
|
||||
<string name="matches_active_team">Active team: %1$s.</string>
|
||||
<string name="matches_multi_team_hint">You have multiple teams: check the one selected above.</string>
|
||||
<string name="matches_no_other">No other matches on the calendar.</string>
|
||||
<string name="matches_retry">Retry</string>
|
||||
<string name="matches_load_error">Loading error</string>
|
||||
<string name="matches_no_team_title">No team assigned</string>
|
||||
<string name="matches_no_team_body">To stream you must be transmission staff for at least one team. Ask your club to add you as streaming staff.</string>
|
||||
<string name="matches_tap_change_team">Tap to change team</string>
|
||||
<string name="matches_team_for_live">Team for the live stream</string>
|
||||
<string name="matches_change">Change</string>
|
||||
<string name="streaming_notification_channel">Live stream</string>
|
||||
<string name="streaming_notification_channel_desc">Notification while live streaming</string>
|
||||
<string name="streaming_notification_title">Match Live TV</string>
|
||||
<string name="streaming_notification_active">Preparing stream…</string>
|
||||
<string name="streaming_notification_connecting">Connecting RTMP…</string>
|
||||
<string name="streaming_notification_streaming">Live</string>
|
||||
<string name="streaming_notification_reconnecting">Reconnecting…</string>
|
||||
<string name="streaming_notification_error">Streaming error</string>
|
||||
<string name="streaming_notification_stop">Stop</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Match Live TV</string>
|
||||
<string name="app_slogan">Cada partido, cada evento, para tus aficionados.</string>
|
||||
<string name="language_label">Idioma</string>
|
||||
<string name="language_system">Sistema</string>
|
||||
<string name="language_italian">Italiano</string>
|
||||
<string name="language_english">English</string>
|
||||
<string name="language_french">Français</string>
|
||||
<string name="language_german">Deutsch</string>
|
||||
<string name="language_spanish">Español</string>
|
||||
<string name="action_logout">Salir</string>
|
||||
<string name="action_login">Acceder</string>
|
||||
<string name="action_cancel">Cancelar</string>
|
||||
<string name="action_save">Guardar</string>
|
||||
<string name="login_email">Email</string>
|
||||
<string name="login_password">Contraseña</string>
|
||||
<string name="login_submit">Acceder</string>
|
||||
<string name="login_error_credentials">Email o contraseña incorrectos</string>
|
||||
<string name="login_error_unreachable">Servidor no disponible. Comprueba la conexión.</string>
|
||||
<string name="login_error_generic">Error de acceso</string>
|
||||
<string name="matches_title">Partidos</string>
|
||||
<string name="matches_hello">Hola, %1$s</string>
|
||||
<string name="matches_subtitle">Reanuda un directo en curso o inicia un partido programado.</string>
|
||||
<string name="matches_schedule">Partido programado</string>
|
||||
<string name="matches_new">Nuevo partido</string>
|
||||
<string name="matches_empty_title">Ningún partido en el calendario</string>
|
||||
<string name="matches_scheduled_title">Partidos programados</string>
|
||||
<string name="matches_ready_title">Listos para empezar</string>
|
||||
<string name="matches_empty_hint">Programa un partido o inicia uno nuevo con «Nuevo partido».</string>
|
||||
<string name="matches_active_team">Equipo activo: %1$s.</string>
|
||||
<string name="matches_multi_team_hint">Tienes varios equipos: comprueba el seleccionado arriba.</string>
|
||||
<string name="matches_no_other">Ningún otro partido en el calendario.</string>
|
||||
<string name="matches_retry">Reintentar</string>
|
||||
<string name="matches_load_error">Error de carga</string>
|
||||
<string name="matches_no_team_title">Ningún equipo asignado</string>
|
||||
<string name="matches_no_team_body">Para emitir debes ser responsable de transmisión de al menos un equipo. Pide a tu club que te añada como staff de streaming.</string>
|
||||
<string name="matches_tap_change_team">Toca para cambiar de equipo</string>
|
||||
<string name="matches_team_for_live">Equipo para el directo</string>
|
||||
<string name="matches_change">Cambiar</string>
|
||||
<string name="streaming_notification_channel">Directo en curso</string>
|
||||
<string name="streaming_notification_channel_desc">Notificación durante el streaming en vivo</string>
|
||||
<string name="streaming_notification_title">Match Live TV</string>
|
||||
<string name="streaming_notification_active">Preparando el directo…</string>
|
||||
<string name="streaming_notification_connecting">Conectando RTMP…</string>
|
||||
<string name="streaming_notification_streaming">En directo</string>
|
||||
<string name="streaming_notification_reconnecting">Reconectando…</string>
|
||||
<string name="streaming_notification_error">Error de streaming</string>
|
||||
<string name="streaming_notification_stop">Terminar</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Match Live TV</string>
|
||||
<string name="app_slogan">Chaque match, chaque événement, pour vos fans.</string>
|
||||
<string name="language_label">Langue</string>
|
||||
<string name="language_system">Système</string>
|
||||
<string name="language_italian">Italiano</string>
|
||||
<string name="language_english">English</string>
|
||||
<string name="language_french">Français</string>
|
||||
<string name="language_german">Deutsch</string>
|
||||
<string name="language_spanish">Español</string>
|
||||
<string name="action_logout">Déconnexion</string>
|
||||
<string name="action_login">Connexion</string>
|
||||
<string name="action_cancel">Annuler</string>
|
||||
<string name="action_save">Enregistrer</string>
|
||||
<string name="login_email">E-mail</string>
|
||||
<string name="login_password">Mot de passe</string>
|
||||
<string name="login_submit">Connexion</string>
|
||||
<string name="login_error_credentials">E-mail ou mot de passe incorrect</string>
|
||||
<string name="login_error_unreachable">Serveur inaccessible. Vérifiez la connexion.</string>
|
||||
<string name="login_error_generic">Échec de la connexion</string>
|
||||
<string name="matches_title">Matchs</string>
|
||||
<string name="matches_hello">Bonjour, %1$s</string>
|
||||
<string name="matches_subtitle">Reprenez un direct en cours ou démarrez un match programmé.</string>
|
||||
<string name="matches_schedule">Match programmé</string>
|
||||
<string name="matches_new">Nouveau match</string>
|
||||
<string name="matches_empty_title">Aucun match au calendrier</string>
|
||||
<string name="matches_scheduled_title">Matchs programmés</string>
|
||||
<string name="matches_ready_title">Prêts à démarrer</string>
|
||||
<string name="matches_empty_hint">Programmez un match ou démarrez-en un avec « Nouveau match ».</string>
|
||||
<string name="matches_active_team">Équipe active : %1$s.</string>
|
||||
<string name="matches_multi_team_hint">Vous avez plusieurs équipes : vérifiez celle sélectionnée ci-dessus.</string>
|
||||
<string name="matches_no_other">Aucun autre match au calendrier.</string>
|
||||
<string name="matches_retry">Réessayer</string>
|
||||
<string name="matches_load_error">Erreur de chargement</string>
|
||||
<string name="matches_no_team_title">Aucune équipe assignée</string>
|
||||
<string name="matches_no_team_body">Pour diffuser, vous devez être responsable de la transmission d\'au moins une équipe. Demandez à votre club de vous ajouter comme staff diffusion.</string>
|
||||
<string name="matches_tap_change_team">Appuyez pour changer d\'équipe</string>
|
||||
<string name="matches_team_for_live">Équipe pour le direct</string>
|
||||
<string name="matches_change">Changer</string>
|
||||
<string name="streaming_notification_channel">Direct en cours</string>
|
||||
<string name="streaming_notification_channel_desc">Notification pendant le streaming live</string>
|
||||
<string name="streaming_notification_title">Match Live TV</string>
|
||||
<string name="streaming_notification_active">Préparation du direct…</string>
|
||||
<string name="streaming_notification_connecting">Connexion RTMP…</string>
|
||||
<string name="streaming_notification_streaming">En direct</string>
|
||||
<string name="streaming_notification_reconnecting">Reconnexion…</string>
|
||||
<string name="streaming_notification_error">Erreur de streaming</string>
|
||||
<string name="streaming_notification_stop">Arrêter</string>
|
||||
</resources>
|
||||
@@ -2,6 +2,42 @@
|
||||
<resources>
|
||||
<string name="app_name">Match Live TV</string>
|
||||
<string name="app_slogan">Ogni partita, ogni evento, per i tuoi tifosi.</string>
|
||||
<string name="language_label">Lingua</string>
|
||||
<string name="language_system">Sistema</string>
|
||||
<string name="language_italian">Italiano</string>
|
||||
<string name="language_english">English</string>
|
||||
<string name="language_french">Français</string>
|
||||
<string name="language_german">Deutsch</string>
|
||||
<string name="language_spanish">Español</string>
|
||||
<string name="action_logout">Esci</string>
|
||||
<string name="action_login">Accedi</string>
|
||||
<string name="action_cancel">Annulla</string>
|
||||
<string name="action_save">Salva</string>
|
||||
<string name="login_email">Email</string>
|
||||
<string name="login_password">Password</string>
|
||||
<string name="login_submit">Accedi</string>
|
||||
<string name="login_error_credentials">Email o password non corretti</string>
|
||||
<string name="login_error_unreachable">Server non raggiungibile. Verifica la connessione.</string>
|
||||
<string name="login_error_generic">Login fallito</string>
|
||||
<string name="matches_title">Partite</string>
|
||||
<string name="matches_hello">Ciao, %1$s</string>
|
||||
<string name="matches_subtitle">Riprendi una diretta in corso o avvia una partita programmata.</string>
|
||||
<string name="matches_schedule">Partita programmata</string>
|
||||
<string name="matches_new">Nuova partita</string>
|
||||
<string name="matches_empty_title">Nessuna partita in calendario</string>
|
||||
<string name="matches_scheduled_title">Partite programmate</string>
|
||||
<string name="matches_ready_title">Pronte da avviare</string>
|
||||
<string name="matches_empty_hint">Programma una partita o avviane una nuova con «Nuova partita».</string>
|
||||
<string name="matches_active_team">Squadra attiva: %1$s.</string>
|
||||
<string name="matches_multi_team_hint">Hai più squadre: verifica quella selezionata sopra.</string>
|
||||
<string name="matches_no_other">Nessuna altra partita in calendario.</string>
|
||||
<string name="matches_retry">Riprova</string>
|
||||
<string name="matches_load_error">Errore caricamento</string>
|
||||
<string name="matches_no_team_title">Nessuna squadra assegnata</string>
|
||||
<string name="matches_no_team_body">Per trasmettere devi essere responsabile della trasmissione di almeno una squadra. Chiedi al tuo club di aggiungerti come staff trasmissione.</string>
|
||||
<string name="matches_tap_change_team">Tocca per cambiare squadra</string>
|
||||
<string name="matches_team_for_live">Squadra per la diretta</string>
|
||||
<string name="matches_change">Cambia</string>
|
||||
<string name="streaming_notification_channel">Diretta in corso</string>
|
||||
<string name="streaming_notification_channel_desc">Notifica durante lo streaming live</string>
|
||||
<string name="streaming_notification_title">Match Live TV</string>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="it" />
|
||||
<locale android:name="en" />
|
||||
<locale android:name="fr" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="es" />
|
||||
</locale-config>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.matchlivetv.match_live_tv.core
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RtmpIngestUrlTest {
|
||||
@Test
|
||||
fun rewritesLoopbackToApiHost() {
|
||||
val resolved = RtmpIngestUrl.resolve(
|
||||
"rtmp://127.0.0.1:1935/live/match_abc",
|
||||
apiBaseUrl = "http://10.0.2.2:3000",
|
||||
)
|
||||
assertEquals("rtmp://10.0.2.2:1935/live/match_abc", resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rewritesDockerInternalHost() {
|
||||
val resolved = RtmpIngestUrl.resolve(
|
||||
"rtmp://mediamtx:1935/live/match_abc",
|
||||
apiBaseUrl = "http://10.0.2.2:3000",
|
||||
)
|
||||
assertEquals("rtmp://10.0.2.2:1935/live/match_abc", resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsProductionHost() {
|
||||
val input = "rtmp://stream.matchlivetv.it:1935/live/match_abc"
|
||||
assertEquals(
|
||||
input,
|
||||
RtmpIngestUrl.resolve(input, apiBaseUrl = "https://www.matchlivetv.it"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("com.android.application") version "8.9.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
C1D2EA29B25C4B06B03B6FFE /* BroadcastVideoOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */; };
|
||||
C3B1B44C77084C05B325F935 /* StepNetworkTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C26A0114F73483A85C70772 /* StepNetworkTestScreen.swift */; };
|
||||
C9B90AAE70C142DEB8996100 /* ApiInstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354D09291641417F99E27D5A /* ApiInstant.swift */; };
|
||||
A1B2C3D4E5F64789A0B1C2D3 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */; };
|
||||
CF8E45A2BFE34E4BBE259654 /* DeviceTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258DECC661314224B6A98F47 /* DeviceTelemetry.swift */; };
|
||||
D08665EFFF27465294B620B5 /* ScoreControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F7A645B14A44C69304E136 /* ScoreControllerTests.swift */; };
|
||||
DBBB006ED6C946A2950D805C /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8992D152CF7F478BA6469E36 /* StepMatchScreen.swift */; };
|
||||
@@ -112,6 +113,7 @@
|
||||
29358C1BDC864C408AB4FAD0 /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = "<group>"; };
|
||||
2DF2832197EE409AA0B0334C /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = "<group>"; };
|
||||
354D09291641417F99E27D5A /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = "<group>"; };
|
||||
B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppLanguage.swift; path = MatchLiveTv/Core/AppLanguage.swift; sourceTree = "<group>"; };
|
||||
3691F2136AAD4545B655F544 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
|
||||
395345091E364AFF9FFB34B9 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = "<group>"; };
|
||||
49FD73A6D4254EAD9484E159 /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = "<group>"; };
|
||||
@@ -218,6 +220,7 @@
|
||||
children = (
|
||||
13EB02413D134D6289DB90DA /* MatchLiveTvApp.swift */,
|
||||
354D09291641417F99E27D5A /* ApiInstant.swift */,
|
||||
B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */,
|
||||
395345091E364AFF9FFB34B9 /* AppConfig.swift */,
|
||||
68D5ED55DB884EA2AC986F61 /* ColorHex.swift */,
|
||||
258DECC661314224B6A98F47 /* DeviceTelemetry.swift */,
|
||||
@@ -393,6 +396,7 @@
|
||||
files = (
|
||||
42EF33716F6843B69945E6AB /* MatchLiveTvApp.swift in Sources */,
|
||||
C9B90AAE70C142DEB8996100 /* ApiInstant.swift in Sources */,
|
||||
A1B2C3D4E5F64789A0B1C2D3 /* AppLanguage.swift in Sources */,
|
||||
066B7E46EB6245BA94C12BA6 /* AppConfig.swift in Sources */,
|
||||
557CA481D63840D3AA6EFD5B /* ColorHex.swift in Sources */,
|
||||
CF8E45A2BFE34E4BBE259654 /* DeviceTelemetry.swift in Sources */,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Preferenza lingua app (override del sistema). Persistita in UserDefaults.
|
||||
enum AppLanguage: String, CaseIterable, Identifiable {
|
||||
case system
|
||||
case it
|
||||
case en
|
||||
case fr
|
||||
case de
|
||||
case es
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var nativeLabel: String {
|
||||
switch self {
|
||||
case .system: return L10n.t("language.system")
|
||||
case .it: return "Italiano"
|
||||
case .en: return "English"
|
||||
case .fr: return "Français"
|
||||
case .de: return "Deutsch"
|
||||
case .es: return "Español"
|
||||
}
|
||||
}
|
||||
|
||||
static var current: AppLanguage {
|
||||
get {
|
||||
let raw = UserDefaults.standard.string(forKey: storageKey) ?? system.rawValue
|
||||
return AppLanguage(rawValue: raw) ?? .system
|
||||
}
|
||||
set {
|
||||
if newValue == .system {
|
||||
UserDefaults.standard.removeObject(forKey: storageKey)
|
||||
} else {
|
||||
UserDefaults.standard.set(newValue.rawValue, forKey: storageKey)
|
||||
}
|
||||
// Forza refresh UI tramite notification
|
||||
NotificationCenter.default.post(name: .appLanguageDidChange, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Locale effettiva per le stringhe (risolve "system").
|
||||
static var resolvedCode: String {
|
||||
switch current {
|
||||
case .system:
|
||||
let code = Locale.current.language.languageCode?.identifier ?? "it"
|
||||
let supported = ["it", "en", "fr", "de", "es"]
|
||||
return supported.contains(code) ? code : "en"
|
||||
default:
|
||||
return current.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
private static let storageKey = "mltv.appLanguage"
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let appLanguageDidChange = Notification.Name("mltv.appLanguageDidChange")
|
||||
}
|
||||
|
||||
enum L10n {
|
||||
static func t(_ key: String) -> String {
|
||||
let code = AppLanguage.resolvedCode
|
||||
if let value = table[code]?[key] { return value }
|
||||
if let value = table["it"]?[key] { return value }
|
||||
return key
|
||||
}
|
||||
|
||||
private static let table: [String: [String: String]] = [
|
||||
"it": [
|
||||
"language.label": "Lingua",
|
||||
"language.system": "Sistema",
|
||||
"action.logout": "Esci",
|
||||
"action.login": "Accedi",
|
||||
"action.cancel": "Annulla",
|
||||
"login.email": "Email",
|
||||
"login.password": "Password",
|
||||
"login.submit": "Accedi",
|
||||
"login.error.credentials": "Email o password non corretti",
|
||||
"login.error.unreachable": "Server non raggiungibile. Verifica la connessione.",
|
||||
"login.error.generic": "Login fallito",
|
||||
"matches.title": "Partite",
|
||||
"app.slogan": "Ogni partita, ogni evento, per i tuoi tifosi.",
|
||||
],
|
||||
"en": [
|
||||
"language.label": "Language",
|
||||
"language.system": "System",
|
||||
"action.logout": "Log out",
|
||||
"action.login": "Log in",
|
||||
"action.cancel": "Cancel",
|
||||
"login.email": "Email",
|
||||
"login.password": "Password",
|
||||
"login.submit": "Log in",
|
||||
"login.error.credentials": "Incorrect email or password",
|
||||
"login.error.unreachable": "Server unreachable. Check your connection.",
|
||||
"login.error.generic": "Login failed",
|
||||
"matches.title": "Matches",
|
||||
"app.slogan": "Every match, every event, for your fans.",
|
||||
],
|
||||
"fr": [
|
||||
"language.label": "Langue",
|
||||
"language.system": "Système",
|
||||
"action.logout": "Déconnexion",
|
||||
"action.login": "Connexion",
|
||||
"action.cancel": "Annuler",
|
||||
"login.email": "E-mail",
|
||||
"login.password": "Mot de passe",
|
||||
"login.submit": "Connexion",
|
||||
"login.error.credentials": "E-mail ou mot de passe incorrect",
|
||||
"login.error.unreachable": "Serveur inaccessible. Vérifiez la connexion.",
|
||||
"login.error.generic": "Échec de la connexion",
|
||||
"matches.title": "Matchs",
|
||||
"app.slogan": "Chaque match, chaque événement, pour vos fans.",
|
||||
],
|
||||
"de": [
|
||||
"language.label": "Sprache",
|
||||
"language.system": "System",
|
||||
"action.logout": "Abmelden",
|
||||
"action.login": "Anmelden",
|
||||
"action.cancel": "Abbrechen",
|
||||
"login.email": "E-Mail",
|
||||
"login.password": "Passwort",
|
||||
"login.submit": "Anmelden",
|
||||
"login.error.credentials": "Falsche E-Mail oder Passwort",
|
||||
"login.error.unreachable": "Server nicht erreichbar. Verbindung prüfen.",
|
||||
"login.error.generic": "Anmeldung fehlgeschlagen",
|
||||
"matches.title": "Spiele",
|
||||
"app.slogan": "Jedes Spiel, jedes Event, für eure Fans.",
|
||||
],
|
||||
"es": [
|
||||
"language.label": "Idioma",
|
||||
"language.system": "Sistema",
|
||||
"action.logout": "Salir",
|
||||
"action.login": "Acceder",
|
||||
"action.cancel": "Cancelar",
|
||||
"login.email": "Email",
|
||||
"login.password": "Contraseña",
|
||||
"login.submit": "Acceder",
|
||||
"login.error.credentials": "Email o contraseña incorrectos",
|
||||
"login.error.unreachable": "Servidor no disponible. Comprueba la conexión.",
|
||||
"login.error.generic": "Error de acceso",
|
||||
"matches.title": "Partidos",
|
||||
"app.slogan": "Cada partido, cada evento, para tus aficionados.",
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
struct LanguagePickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selected = AppLanguage.current
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(AppLanguage.allCases) { lang in
|
||||
Button {
|
||||
selected = lang
|
||||
AppLanguage.current = lang
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(lang.nativeLabel)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
Spacer()
|
||||
if selected == lang {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.t("language.label"))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.t("action.cancel")) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ struct LoginScreen: View {
|
||||
@State private var error: String?
|
||||
@State private var loading = false
|
||||
@State private var passwordVisible = false
|
||||
@State private var showLanguagePicker = false
|
||||
@State private var languageTick = 0
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold {
|
||||
@@ -16,17 +18,17 @@ struct LoginScreen: View {
|
||||
VStack(spacing: 0) {
|
||||
MatchLiveWordmark(showSlogan: true)
|
||||
.padding(.top, 32)
|
||||
Text("ACCEDI")
|
||||
Button(L10n.t("language.label")) {
|
||||
showLanguagePicker = true
|
||||
}
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.top, 16)
|
||||
Text(L10n.t("login.submit").uppercased())
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.padding(.top, 48)
|
||||
Text("Gestisci le dirette della tua squadra")
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
.padding(.top, 32)
|
||||
VStack(spacing: 16) {
|
||||
MatchTextField(title: "Email", text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
|
||||
MatchSecureField(title: "Password", text: $password, visible: $passwordVisible)
|
||||
MatchTextField(title: L10n.t("login.email"), text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
|
||||
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
if let error {
|
||||
@@ -36,7 +38,7 @@ struct LoginScreen: View {
|
||||
.padding(.top, 12)
|
||||
}
|
||||
MatchPrimaryButton(
|
||||
label: "ACCEDI",
|
||||
label: L10n.t("login.submit").uppercased(),
|
||||
action: submitLogin,
|
||||
enabled: !email.isEmpty && !password.isEmpty,
|
||||
loading: loading
|
||||
@@ -47,6 +49,13 @@ struct LoginScreen: View {
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showLanguagePicker) {
|
||||
LanguagePickerView()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||
languageTick += 1
|
||||
}
|
||||
.id(languageTick)
|
||||
}
|
||||
|
||||
private func submitLogin() {
|
||||
@@ -59,12 +68,12 @@ struct LoginScreen: View {
|
||||
onLoggedIn()
|
||||
} catch {
|
||||
if let apiError = error as? APIError, case .unauthorized = apiError {
|
||||
self.error = "Email o password non corretti"
|
||||
self.error = L10n.t("login.error.credentials")
|
||||
} else if let message = UserFacingError.message(for: error),
|
||||
message.localizedCaseInsensitiveContains("timeout") {
|
||||
self.error = "Server non raggiungibile. Verifica la connessione."
|
||||
self.error = L10n.t("login.error.unreachable")
|
||||
} else {
|
||||
self.error = UserFacingError.message(for: error) ?? "Accesso non riuscito"
|
||||
self.error = UserFacingError.message(for: error) ?? L10n.t("login.error.generic")
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
|
||||
@@ -20,6 +20,8 @@ struct MatchesScreen: View {
|
||||
@State private var resumeMatch: Match?
|
||||
@State private var deleteMatch: Match?
|
||||
@State private var snackbar: String?
|
||||
@State private var showLanguagePicker = false
|
||||
@State private var languageTick = 0
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold(
|
||||
@@ -27,7 +29,14 @@ struct MatchesScreen: View {
|
||||
HStack {
|
||||
MatchLiveWordmark(compact: true)
|
||||
Spacer()
|
||||
Button("Esci") {
|
||||
Button {
|
||||
showLanguagePicker = true
|
||||
} label: {
|
||||
Image(systemName: "globe")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("language.label"))
|
||||
Button(L10n.t("action.logout")) {
|
||||
Task {
|
||||
await container.authRepository.logout()
|
||||
onLogout()
|
||||
@@ -177,6 +186,13 @@ struct MatchesScreen: View {
|
||||
showTeamPicker = false
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showLanguagePicker) {
|
||||
LanguagePickerView()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||
languageTick += 1
|
||||
}
|
||||
.id(languageTick)
|
||||
.overlay {
|
||||
if actionLoading {
|
||||
Color.black.opacity(0.35)
|
||||
|
||||
Reference in New Issue
Block a user