Add a Competitions card to the control panel's Features page
Competition rows (events.services.competitions' per-club data-source gate) could previously only be managed through the Django admin. Adds a card alongside Flags/Switches with create/edit/delete, mirroring the Flags card's own modal pattern -- no cascading effects to weigh on delete since Event.competition matches by name, not a foreign key. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -6,7 +6,7 @@ from waffle import get_waffle_flag_model
|
|||||||
|
|
||||||
from billing.models import DuePayment, Plan, PlanPrice, Subscription
|
from billing.models import DuePayment, Plan, PlanPrice, Subscription
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from events.models import Location
|
from events.models import Competition, Location
|
||||||
|
|
||||||
from .services.admins import find_member_by_email
|
from .services.admins import find_member_by_email
|
||||||
|
|
||||||
@@ -82,6 +82,24 @@ class FlagForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionForm(forms.ModelForm):
|
||||||
|
"""Metadata for events.services.competitions.fetch_game_info's per-club gate --
|
||||||
|
`module` is a dotted import path to a class named `name` that implements
|
||||||
|
`update_game_information(event=...)`; there is no such class for a new
|
||||||
|
competition until one is actually written, but that's fine here, same as
|
||||||
|
editing this by hand in the Django admin today: fetch_game_info already
|
||||||
|
catches the resulting ImportError/AttributeError and treats it as "nothing to
|
||||||
|
fetch from" rather than a 500, so this form doesn't need to validate the path
|
||||||
|
against real code to be safe to use."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Competition
|
||||||
|
fields = ["name", "module", "sport_type", "flag"]
|
||||||
|
help_texts = {
|
||||||
|
"module": _("Dotted path to the Python module implementing this competition's data source, e.g. events.services.competitions.cehl."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PlanForm(forms.ModelForm):
|
class PlanForm(forms.ModelForm):
|
||||||
"""Field order is chosen for the two-column modal (see _form_fields.html): description
|
"""Field order is chosen for the two-column modal (see _form_fields.html): description
|
||||||
spans both columns, so pairing name with duration and the two day-counts with each other
|
spans both columns, so pairing name with duration and the two day-counts with each other
|
||||||
|
|||||||
@@ -9,15 +9,20 @@
|
|||||||
<span>{{ flags|length }} flag{{ flags|length|pluralize }}</span>
|
<span>{{ flags|length }} flag{{ flags|length|pluralize }}</span>
|
||||||
<span class="text-edge">|</span>
|
<span class="text-edge">|</span>
|
||||||
<span>{{ switches|length }} switch{{ switches|length|pluralize }}</span>
|
<span>{{ switches|length }} switch{{ switches|length|pluralize }}</span>
|
||||||
|
<span class="text-edge">|</span>
|
||||||
|
<span>{{ competitions|length }} competition{{ competitions|length|pluralize }}</span>
|
||||||
{% endblock breadcrumb %}
|
{% endblock breadcrumb %}
|
||||||
|
|
||||||
{% block actions %}
|
{% block actions %}
|
||||||
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('flag_create_modal').showModal()">{% lucide "plus" size=16 %} New feature</button>
|
<button class="btn btn-primary gap-2" type="button" onclick="document.getElementById('flag_create_modal').showModal()">{% lucide "plus" size=16 %} New feature</button>
|
||||||
|
<button class="btn btn-outline gap-2" type="button" onclick="document.getElementById('competition_create_modal').showModal()">{% lucide "plus" size=16 %} New competition</button>
|
||||||
{% endblock actions %}
|
{% endblock actions %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
{% url 'controlpanel:flag_create' as flag_create_url %}
|
{% url 'controlpanel:flag_create' as flag_create_url %}
|
||||||
{% include "controlpanel/_modal_form.html" with modal_id="flag_create_modal" title="New feature" form=flag_form action_url=flag_create_url submit_label="Create" submit_icon="plus" %}
|
{% include "controlpanel/_modal_form.html" with modal_id="flag_create_modal" title="New feature" form=flag_form action_url=flag_create_url submit_label="Create" submit_icon="plus" %}
|
||||||
|
{% url 'controlpanel:competition_create' as competition_create_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id="competition_create_modal" title="New competition" form=competition_form action_url=competition_create_url submit_label="Create" submit_icon="plus" %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the
|
The lock-down. Clubs get a maintenance page, the scheduled jobs stand down, and the
|
||||||
@@ -171,4 +176,64 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
|
||||||
|
{% lucide "trophy" size=16 class="shrink-0 text-muted" %}
|
||||||
|
<span class="font-display text-sm font-extrabold tracking-[.1em] text-ink uppercase">Competitions</span>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<span class="font-mono text-[11px] text-muted">{{ competitions|length }} competition{{ competitions|length|pluralize }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="px-4 pt-3 pb-3 text-sm text-muted">
|
||||||
|
Data sources a game's score/status can be fetched from — see events.services.competitions. A club only sees one in its event form once its feature flag is on for them.
|
||||||
|
</p>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Sport</th>
|
||||||
|
<th>Module</th>
|
||||||
|
<th>Flag</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for competition in competitions %}
|
||||||
|
<tr>
|
||||||
|
<td class="font-mono">{{ competition.name }}</td>
|
||||||
|
<td>{{ competition.get_sport_type_display }}</td>
|
||||||
|
<td class="max-w-xs truncate text-muted">{{ competition.module }}</td>
|
||||||
|
<td>
|
||||||
|
{% if competition.flag %}
|
||||||
|
<span class="badge badge-info">{{ competition.flag.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-neutral">No flag — hidden everywhere</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="flex justify-end gap-1">
|
||||||
|
<button class="btn btn-outline btn-sm gap-1" type="button" onclick="document.getElementById('{{ competition.pk|dom_id:"competition_edit_modal" }}').showModal()">{% lucide "pencil" size=14 %} Edit</button>
|
||||||
|
<button class="btn btn-outline btn-error btn-sm gap-1" type="button" onclick="document.getElementById('{{ competition.pk|dom_id:"competition_delete_modal" }}').showModal()">{% lucide "trash-2" size=14 %} Delete</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center text-muted">No competitions yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} Dialogs live outside the table: <tbody> may only contain <tr> elements. {% endcomment %}
|
||||||
|
{% for competition in competitions %}
|
||||||
|
{% url 'controlpanel:competition_update' competition.pk as competition_update_url %}
|
||||||
|
{% include "controlpanel/_modal_form.html" with modal_id=competition.pk|dom_id:"competition_edit_modal" title="Edit "|add:competition.name form=competition.edit_form action_url=competition_update_url submit_label="Save" submit_icon="check" %}
|
||||||
|
|
||||||
|
{% url 'controlpanel:competition_delete' competition.pk as competition_delete_url %}
|
||||||
|
{% include "controlpanel/_confirm_modal.html" with modal_id=competition.pk|dom_id:"competition_delete_modal" title="Delete "|add:competition.name body="No club-visible data is lost — Event.competition matches by name, not a foreign key, so existing games simply stop offering a live-score fetch." action_url=competition_delete_url submit_label="Delete" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endblock panel %}
|
{% endblock panel %}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from billing.models import DEFAULT_GRACE_DAYS, Due, Plan, PlanPrice, Subscriptio
|
|||||||
from billing.services import BillingError
|
from billing.services import BillingError
|
||||||
from billing.services.dues import record_payment, start_trial, subscribe, waive
|
from billing.services.dues import record_payment, start_trial, subscribe, waive
|
||||||
from club.models import Club, ClubMembership, ClubRole, Season
|
from club.models import Club, ClubMembership, ClubRole, Season
|
||||||
from events.models import Attendance, Event, Location
|
from events.models import Attendance, Competition, Event, Location
|
||||||
from features.models import Maintenance
|
from features.models import Maintenance
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from shop.models import Order
|
from shop.models import Order
|
||||||
@@ -601,6 +601,68 @@ class FeatureViewTests(ControlPanelTestBase):
|
|||||||
self.assertNotContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk]))
|
self.assertNotContains(response, reverse("controlpanel:club_feature_toggle", args=[self.club.pk, self.flag.pk]))
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionCrudTests(ControlPanelTestBase):
|
||||||
|
"""Manage events.models.Competition rows from the Features page -- see
|
||||||
|
events.services.competitions for how `module`/`name` get used."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
super().setUpTestData()
|
||||||
|
cls.competition = Competition.objects.create(name="CEHL", module="events.services.competitions.cehl", sport_type=Club.SportType.OTHER)
|
||||||
|
|
||||||
|
def test_features_page_lists_competitions(self):
|
||||||
|
response = self.client.get(reverse("controlpanel:features"))
|
||||||
|
|
||||||
|
self.assertContains(response, "CEHL")
|
||||||
|
self.assertContains(response, "events.services.competitions.cehl")
|
||||||
|
|
||||||
|
def test_the_competition_forms_are_post_only(self):
|
||||||
|
self.assertEqual(self.client.get(reverse("controlpanel:competition_create")).status_code, 405)
|
||||||
|
self.assertEqual(self.client.get(reverse("controlpanel:competition_update", args=[self.competition.pk])).status_code, 405)
|
||||||
|
|
||||||
|
def test_an_invalid_competition_submission_redirects_with_a_message(self):
|
||||||
|
response = self.client.post(reverse("controlpanel:competition_create"), {"name": "", "module": "", "sport_type": Club.SportType.OTHER}, follow=True)
|
||||||
|
|
||||||
|
self.assertRedirects(response, reverse("controlpanel:features"))
|
||||||
|
self.assertContains(response, "This field is required")
|
||||||
|
|
||||||
|
def test_create_a_competition(self):
|
||||||
|
self.client.post(reverse("controlpanel:competition_create"), {"name": "BFL", "module": "events.services.competitions.bfl", "sport_type": Club.SportType.OTHER})
|
||||||
|
|
||||||
|
self.assertTrue(Competition.objects.filter(name="BFL").exists())
|
||||||
|
|
||||||
|
def test_edit_a_competition(self):
|
||||||
|
self.client.post(reverse("controlpanel:competition_update", args=[self.competition.pk]), {"name": "CEHL", "module": "events.services.competitions.cehl_v2", "sport_type": Club.SportType.OTHER})
|
||||||
|
|
||||||
|
self.competition.refresh_from_db()
|
||||||
|
self.assertEqual(self.competition.module, "events.services.competitions.cehl_v2")
|
||||||
|
|
||||||
|
def test_delete_a_competition(self):
|
||||||
|
self.client.post(reverse("controlpanel:competition_delete", args=[self.competition.pk]))
|
||||||
|
|
||||||
|
self.assertFalse(Competition.objects.filter(pk=self.competition.pk).exists())
|
||||||
|
|
||||||
|
def test_deleting_does_not_touch_an_event_that_matched_it_by_name(self):
|
||||||
|
# Event.competition is a plain name match, not a foreign key -- deleting the
|
||||||
|
# Competition row must not cascade or error.
|
||||||
|
season = Season.objects.create(club=self.club, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
|
||||||
|
event = Event.objects.create(club=self.club, season=season, kind=Event.EventKind.GAME, title="Match", start=timezone.now(), competition="CEHL")
|
||||||
|
|
||||||
|
self.client.post(reverse("controlpanel:competition_delete", args=[self.competition.pk]))
|
||||||
|
|
||||||
|
event.refresh_from_db()
|
||||||
|
self.assertEqual(event.competition, "CEHL")
|
||||||
|
|
||||||
|
def test_a_non_staff_user_gets_redirected(self):
|
||||||
|
self.client.logout()
|
||||||
|
plain_user = User.objects.create_user(email="plain-competition@example.com", password="pw-secret-123")
|
||||||
|
self.client.force_login(plain_user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("controlpanel:features"))
|
||||||
|
|
||||||
|
self.assertNotEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class NotifyTests(TestCase):
|
class NotifyTests(TestCase):
|
||||||
def request(self):
|
def request(self):
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ urlpatterns = [
|
|||||||
path("features/flags/new/", views.FlagCreateView.as_view(), name="flag_create"),
|
path("features/flags/new/", views.FlagCreateView.as_view(), name="flag_create"),
|
||||||
path("features/flags/<int:pk>/edit/", views.FlagUpdateView.as_view(), name="flag_update"),
|
path("features/flags/<int:pk>/edit/", views.FlagUpdateView.as_view(), name="flag_update"),
|
||||||
path("features/switches/<int:pk>/toggle/", views.SwitchToggleView.as_view(), name="switch_toggle"),
|
path("features/switches/<int:pk>/toggle/", views.SwitchToggleView.as_view(), name="switch_toggle"),
|
||||||
|
path("features/competitions/new/", views.CompetitionCreateView.as_view(), name="competition_create"),
|
||||||
|
path("features/competitions/<int:pk>/edit/", views.CompetitionUpdateView.as_view(), name="competition_update"),
|
||||||
|
path("features/competitions/<int:pk>/delete/", views.CompetitionDeleteView.as_view(), name="competition_delete"),
|
||||||
# Billing (platform charging the clubs)
|
# Billing (platform charging the clubs)
|
||||||
path("billing/", views.BillingView.as_view(), name="billing"),
|
path("billing/", views.BillingView.as_view(), name="billing"),
|
||||||
path("billing/plans/new/", views.PlanCreateView.as_view(), name="plan_create"),
|
path("billing/plans/new/", views.PlanCreateView.as_view(), name="plan_create"),
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ from billing.services.dues import next_period_start, open_period, reactivate, re
|
|||||||
from billing.services.invoices import invoice_pdf, issue_invoice
|
from billing.services.invoices import invoice_pdf, issue_invoice
|
||||||
from billing.services.plans import delete_plan, plan_deletion_impact
|
from billing.services.plans import delete_plan, plan_deletion_impact
|
||||||
from club.models import Club, ClubRole
|
from club.models import Club, ClubRole
|
||||||
from events.models import Location
|
from events.models import Competition, Location
|
||||||
from features.models import Maintenance
|
from features.models import Maintenance
|
||||||
|
|
||||||
from .forms import ClubAdminForm, ClubForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlanForm, PlanPriceForm, PlatformAdminForm, SubscriptionForm, TrialForm
|
from .forms import ClubAdminForm, ClubForm, CompetitionForm, DuePaymentForm, FlagForm, HomeLocationForm, MaintenanceForm, OpenPeriodForm, PlanForm, PlanPriceForm, PlatformAdminForm, SubscriptionForm, TrialForm
|
||||||
from .messages import notify
|
from .messages import notify
|
||||||
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
|
from .mixins import PlatformStaffRequiredMixin, PlatformSuperuserRequiredMixin, RedirectOnInvalidMixin
|
||||||
from .services.admins import grant_club_admin, revoke_club_admin
|
from .services.admins import grant_club_admin, revoke_club_admin
|
||||||
@@ -285,10 +285,16 @@ class FeatureListView(PlatformStaffRequiredMixin, TemplateView):
|
|||||||
for flag in flags:
|
for flag in flags:
|
||||||
flag.edit_form = FlagForm(instance=flag)
|
flag.edit_form = FlagForm(instance=flag)
|
||||||
|
|
||||||
|
competitions = list(Competition.objects.select_related("flag").order_by("name"))
|
||||||
|
for competition in competitions:
|
||||||
|
competition.edit_form = CompetitionForm(instance=competition)
|
||||||
|
|
||||||
return super().get_context_data(
|
return super().get_context_data(
|
||||||
nav="features",
|
nav="features",
|
||||||
flags=flags,
|
flags=flags,
|
||||||
flag_form=FlagForm(),
|
flag_form=FlagForm(),
|
||||||
|
competitions=competitions,
|
||||||
|
competition_form=CompetitionForm(),
|
||||||
switches=Switch.objects.order_by("name"),
|
switches=Switch.objects.order_by("name"),
|
||||||
maintenance=Maintenance.current(),
|
maintenance=Maintenance.current(),
|
||||||
maintenance_form=MaintenanceForm(),
|
maintenance_form=MaintenanceForm(),
|
||||||
@@ -348,6 +354,58 @@ class FlagUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateV
|
|||||||
return reverse("controlpanel:features")
|
return reverse("controlpanel:features")
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionCreateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, CreateView):
|
||||||
|
"""Reachable only via the "New competition" modal on the features page --
|
||||||
|
POST-only, and there is no standalone template to render on GET or on a
|
||||||
|
rejected submission."""
|
||||||
|
|
||||||
|
model = Competition
|
||||||
|
form_class = CompetitionForm
|
||||||
|
http_method_names = ["post"]
|
||||||
|
invalid_redirect_url_name = "controlpanel:features"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
notify(self.request, f"s|Competition created|Competition “{self.object.name}” created.")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("controlpanel:features")
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionUpdateView(PlatformStaffRequiredMixin, RedirectOnInvalidMixin, UpdateView):
|
||||||
|
"""Reachable only via a competition's "Edit" modal on the features page --
|
||||||
|
POST-only, and there is no standalone template to render on GET or on a
|
||||||
|
rejected submission."""
|
||||||
|
|
||||||
|
model = Competition
|
||||||
|
form_class = CompetitionForm
|
||||||
|
http_method_names = ["post"]
|
||||||
|
invalid_redirect_url_name = "controlpanel:features"
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
notify(self.request, f"s|Competition updated|Competition “{self.object.name}” updated.")
|
||||||
|
return response
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("controlpanel:features")
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionDeleteView(PlatformStaffRequiredMixin, View):
|
||||||
|
"""No cascading consequences to weigh (Event.competition matches by name, not
|
||||||
|
a foreign key -- see events.services.competitions), so this is a plain confirm
|
||||||
|
modal rather than PlanDeleteView's dedicated impact page."""
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
competition = get_object_or_404(Competition, pk=pk)
|
||||||
|
name = competition.name
|
||||||
|
competition.delete()
|
||||||
|
|
||||||
|
notify(request, f"w|Competition deleted|Competition “{name}” has been deleted.")
|
||||||
|
return redirect("controlpanel:features")
|
||||||
|
|
||||||
|
|
||||||
class SwitchToggleView(PlatformStaffRequiredMixin, View):
|
class SwitchToggleView(PlatformStaffRequiredMixin, View):
|
||||||
"""Global kill-switch: on or off for the whole platform."""
|
"""Global kill-switch: on or off for the whole platform."""
|
||||||
|
|
||||||
|
|||||||
@@ -3497,6 +3497,9 @@
|
|||||||
.ml-2 {
|
.ml-2 {
|
||||||
margin-left: calc(var(--spacing) * 2);
|
margin-left: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.ml-auto {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
.status {
|
.status {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -3880,6 +3883,12 @@
|
|||||||
.h-48 {
|
.h-48 {
|
||||||
height: calc(var(--spacing) * 48);
|
height: calc(var(--spacing) * 48);
|
||||||
}
|
}
|
||||||
|
.h-52 {
|
||||||
|
height: calc(var(--spacing) * 52);
|
||||||
|
}
|
||||||
|
.h-56 {
|
||||||
|
height: calc(var(--spacing) * 56);
|
||||||
|
}
|
||||||
.h-72 {
|
.h-72 {
|
||||||
height: calc(var(--spacing) * 72);
|
height: calc(var(--spacing) * 72);
|
||||||
}
|
}
|
||||||
@@ -3913,9 +3922,15 @@
|
|||||||
.h-\[190px\] {
|
.h-\[190px\] {
|
||||||
height: 190px;
|
height: 190px;
|
||||||
}
|
}
|
||||||
|
.h-fit {
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
.h-full {
|
.h-full {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.h-max {
|
||||||
|
height: max-content;
|
||||||
|
}
|
||||||
.h-px {
|
.h-px {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
}
|
}
|
||||||
@@ -3928,6 +3943,9 @@
|
|||||||
.max-h-96 {
|
.max-h-96 {
|
||||||
max-height: calc(var(--spacing) * 96);
|
max-height: calc(var(--spacing) * 96);
|
||||||
}
|
}
|
||||||
|
.max-h-none {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
.min-h-6 {
|
.min-h-6 {
|
||||||
min-height: calc(var(--spacing) * 6);
|
min-height: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
@@ -4174,6 +4192,9 @@
|
|||||||
.grid-cols-3 {
|
.grid-cols-3 {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
.grid-cols-4 {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
.grid-cols-5 {
|
.grid-cols-5 {
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -4317,6 +4338,9 @@
|
|||||||
.rounded-md {
|
.rounded-md {
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
.rounded-none {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
.rounded-sm {
|
.rounded-sm {
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
@@ -4339,6 +4363,10 @@
|
|||||||
border-top-style: var(--tw-border-style);
|
border-top-style: var(--tw-border-style);
|
||||||
border-top-width: 1px;
|
border-top-width: 1px;
|
||||||
}
|
}
|
||||||
|
.border-r {
|
||||||
|
border-right-style: var(--tw-border-style);
|
||||||
|
border-right-width: 1px;
|
||||||
|
}
|
||||||
.border-b {
|
.border-b {
|
||||||
border-bottom-style: var(--tw-border-style);
|
border-bottom-style: var(--tw-border-style);
|
||||||
border-bottom-width: 1px;
|
border-bottom-width: 1px;
|
||||||
@@ -4504,6 +4532,31 @@
|
|||||||
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
|
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.btn-soft {
|
||||||
|
@layer daisyui.l1.l2.l3 {
|
||||||
|
--btn-bg: var(--btn-color, var(--color-base-content));
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
--btn-bg: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--btn-color, var(--color-base-content)) 8%,
|
||||||
|
var(--btn-soft-bg, var(--color-base-100))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
color: var(--btn-rest-fg, var(--btn-color, var(--color-base-content)));
|
||||||
|
--btn-border: var(--btn-color, var(--color-base-content));
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
--btn-border: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--btn-color, var(--color-base-content)) 10%,
|
||||||
|
var(--btn-soft-bg, var(--color-base-100))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
--btn-border-style: solid;
|
||||||
|
background-image: none;
|
||||||
|
--btn-inset: 0 0 0 0 oklch(0% 0 0/0);
|
||||||
|
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
|
||||||
|
}
|
||||||
|
}
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@layer daisyui.l1.l2.l3 {
|
@layer daisyui.l1.l2.l3 {
|
||||||
--btn-bg: #0000;
|
--btn-bg: #0000;
|
||||||
@@ -4526,6 +4579,9 @@
|
|||||||
.object-cover {
|
.object-cover {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
.p-0 {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
.p-1 {
|
.p-1 {
|
||||||
padding: var(--spacing);
|
padding: var(--spacing);
|
||||||
}
|
}
|
||||||
@@ -5027,6 +5083,9 @@
|
|||||||
.opacity-0 {
|
.opacity-0 {
|
||||||
opacity: 0%;
|
opacity: 0%;
|
||||||
}
|
}
|
||||||
|
.opacity-40 {
|
||||||
|
opacity: 40%;
|
||||||
|
}
|
||||||
.opacity-50 {
|
.opacity-50 {
|
||||||
opacity: 50%;
|
opacity: 50%;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user