add feed in / out button page and also button to redirect to button page

This commit is contained in:
Alberto-Audrix committed 2026-09-02 13:33:41 +07:00
1 parent 07b8e2dc25
commit b86bd7a2f4
17 files changed
+719 -28

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import React from 'react';
import { AuthProvider, useAuth } from './context/AuthContext.tsx';
import { FarmProvider } from './context/FarmContext.tsx';
import AuthPage from './components/auth/AuthPage.tsx';
import FeedInButtonPanel from './components/feedInButton/FeedInButtonPanel.tsx';
import { Banner, Spinner } from './components/ui/Feedback.tsx';
import { useFarm } from './context/FarmContext.tsx';
const ButtonContent: React.FC = () => {
const { error } = useFarm();
return (
<div className="min-h-screen bg-gray-100 flex flex-col">
<header className="bg-white border-b border-gray-200 px-6 py-4">
<div className="max-w-5xl mx-auto flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<img src="/favicon.png" alt="Logo" className="h-10 object-contain" />
<div>
<h1 className="text-xl font-bold text-gray-800">Feed In/Out Button</h1>
<p className="text-sm text-gray-500">Pilih kandang untuk memulai hitung feed in</p>
</div>
</div>
<a
href="/"
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 hover:text-red-600 shrink-0"
>
<i className="fa-solid fa-arrow-left" />
Kembali ke Dashboard
</a>
</div>
</header>
<main className="flex-1 px-6 py-10">
{error && (
<div className="max-w-5xl mx-auto mb-6">
<Banner kind="error">{error}</Banner>
</div>
)}
<FeedInButtonPanel variant="kiosk" />
</main>
</div>
);
};
const Gate: React.FC = () => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Spinner />
</div>
);
}
if (!user) return <AuthPage />;
return (
<FarmProvider>
<ButtonContent />
</FarmProvider>
);
};
const ButtonApp: React.FC = () => (
<AuthProvider>
<Gate />
</AuthProvider>
);
export default ButtonApp;
@@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0012_cycle_feed_initial_balance_date"),
]
operations = [
migrations.AddField(
model_name="kandang",
name="feed_in_button_url",
field=models.URLField(blank=True, default="", max_length=500),
),
]
@@ -0,0 +1,29 @@
from django.db import migrations, models
def migrate_single_url_to_list(apps, schema_editor):
Kandang = apps.get_model("farms", "Kandang")
for kandang in Kandang.objects.exclude(feed_in_button_url="").exclude(feed_in_button_url__isnull=True):
url = (kandang.feed_in_button_url or "").strip()
if url:
kandang.feed_in_button_urls = [{"label": "", "url": url}]
kandang.save(update_fields=["feed_in_button_urls"])
class Migration(migrations.Migration):
dependencies = [
("farms", "0013_kandang_feed_in_button_url"),
]
operations = [
migrations.AddField(
model_name="kandang",
name="feed_in_button_urls",
field=models.JSONField(blank=True, default=list),
),
migrations.RunPython(migrate_single_url_to_list, migrations.RunPython.noop),
migrations.RemoveField(
model_name="kandang",
name="feed_in_button_url",
),
]
+1
View File
@@ -24,6 +24,7 @@ class Site(models.Model):
class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True)
kandang_name = models.CharField(max_length=30)
feed_in_button_urls = models.JSONField(default=list, blank=True)
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="kandangs")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+45 -1
View File
@@ -11,12 +11,56 @@ class SiteSerializer(PkAsIdMixin, serializers.ModelSerializer):
read_only_fields = ["id", "created_at", "updated_at"]
class FeedInButtonUrlSerializer(serializers.Serializer):
label = serializers.CharField(max_length=50, required=False, allow_blank=True, default="")
url = serializers.URLField(max_length=500)
class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
feed_in_button_urls = FeedInButtonUrlSerializer(many=True, required=False)
class Meta:
model = Kandang
fields = ["id", "kandang_name", "site", "created_at", "updated_at"]
fields = [
"id",
"kandang_name",
"feed_in_button_urls",
"site",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
def validate_feed_in_button_urls(self, value):
cleaned = []
for item in value or []:
url = str(item.get("url", "")).strip()
if not url:
continue
label = str(item.get("label", "") or "").strip()[:50]
cleaned.append({"label": label, "url": url})
return cleaned
def update(self, instance, validated_data):
urls = validated_data.pop("feed_in_button_urls", None)
instance = super().update(instance, validated_data)
if urls is not None:
instance.feed_in_button_urls = urls
instance.save(update_fields=["feed_in_button_urls", "updated_at"])
return instance
def to_representation(self, instance):
data = super().to_representation(instance)
urls = data.get("feed_in_button_urls") or []
if not isinstance(urls, list):
urls = []
data["feed_in_button_urls"] = [
{"label": str(item.get("label", "") or ""), "url": str(item.get("url", "") or "")}
for item in urls
if isinstance(item, dict) and str(item.get("url", "") or "").strip()
]
return data
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
@@ -0,0 +1,77 @@
from django.test import Client, TestCase
from apps.accounts.models import User
from apps.farms.models import Kandang, Site
class KandangFeedInButtonUrlTests(TestCase):
def setUp(self):
self.owner = User.objects.create_user(user_name="owner", password="secret")
self.admin = User.objects.create_user(
user_name="admin",
password="admin123",
status=User.STATUS_SUPERADMIN,
is_superuser=True,
)
self.staff = User.objects.create_user(
user_name="staff",
password="staff123",
status=User.STATUS_ACTIVE,
is_staff=True,
)
self.site = Site.objects.create(site_name="Site A", user=self.owner)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
self.payload = {
"feed_in_button_urls": [{"label": "Gudang A", "url": "http://192.168.1.10:8080/"}]
}
def _csrf_headers(self, client: Client) -> dict[str, str]:
token = client.cookies["csrftoken"].value
return {"HTTP_X_CSRFTOKEN": token}
def _login(self, client: Client, user_name: str, password: str) -> None:
client.get("/api/v1/auth/csrf/")
response = client.post(
"/api/v1/auth/login/",
{"user_name": user_name, "password": password},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
def test_superadmin_can_save_feed_in_button_urls(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "admin", "admin123")
response = client.patch(
f"/api/v1/kandangs/{self.kandang.pk}/",
self.payload,
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 200, response.content)
self.kandang.refresh_from_db()
self.assertEqual(self.kandang.feed_in_button_urls, self.payload["feed_in_button_urls"])
def test_staff_cannot_save_feed_in_button_urls(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "staff", "staff123")
response = client.patch(
f"/api/v1/kandangs/{self.kandang.pk}/",
self.payload,
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 403, response.content)
self.kandang.refresh_from_db()
self.assertEqual(self.kandang.feed_in_button_urls, [])
def test_staff_cannot_rename_kandang(self):
client = Client(enforce_csrf_checks=True)
self._login(client, "staff", "staff123")
response = client.patch(
f"/api/v1/kandangs/{self.kandang.pk}/",
{"kandang_name": "Renamed"},
content_type="application/json",
**self._csrf_headers(client),
)
self.assertEqual(response.status_code, 403, response.content)
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Feed In/Out Button</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
</head>
<body class="bg-gray-100">
<div id="root"></div>
<script type="module" src="/button/main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import ButtonApp from '../ButtonApp.tsx';
import ErrorBoundary from '../components/common/ErrorBoundary.tsx';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Could not find root element to mount to');
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<ErrorBoundary>
<ButtonApp />
</ErrorBoundary>
</React.StrictMode>
);
+8
View File
@@ -98,6 +98,14 @@ const Header: React.FC<HeaderProps> = ({
))}
</select>
</div>
<a
href="/button/"
title="Feed In/Out Button"
className="inline-flex items-center gap-2 px-3 py-1.5 border border-red-200 rounded-md bg-red-50 text-sm font-semibold text-red-700 hover:bg-red-100 hover:border-red-300"
>
<i className="fa-solid fa-hand-pointer" />
<span className="hidden sm:inline">Feed In/Out Button</span>
</a>
</div>
<div className="flex items-center ml-auto">
<button
+101 -1
View File
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { api, errorMessage } from '../services/apiClient.ts';
import { useAuth } from '../context/AuthContext.tsx';
import { useFarm } from '../context/FarmContext.tsx';
import type { Cycle } from '../types/api.ts';
import type { Cycle, FeedInButtonUrl } from '../types/api.ts';
import { Banner, PageHeader } from './ui/Feedback.tsx';
import { addDaysIso, formatDateId, inclusiveDayCount, resolveCycleStatus, todayIso } from '../utils/format.ts';
@@ -23,6 +23,8 @@ const cycleToForm = (cycle: Cycle) => ({
chickInWeight: cycle.chick_in_weight,
});
const emptyFeedInButtonUrl = (): FeedInButtonUrl => ({ label: '', url: '' });
const SettingsPage: React.FC = () => {
const { user } = useAuth();
const {
@@ -42,6 +44,7 @@ const SettingsPage: React.FC = () => {
const [saving, setSaving] = useState(false);
const [siteName, setSiteName] = useState('');
const [kandangName, setKandangName] = useState('');
const [feedInButtonUrls, setFeedInButtonUrls] = useState<FeedInButtonUrl[]>([emptyFeedInButtonUrl()]);
const [flockName, setFlockName] = useState('');
const [editingCycleId, setEditingCycleId] = useState<number | null>(null);
const [startDate, setStartDate] = useState(defaultCycleForm().startDate);
@@ -84,6 +87,13 @@ const SettingsPage: React.FC = () => {
const isSuperAdmin = user?.status === 'superadmin';
const selectedKandang = kandangs.find((k) => k.id === selectedKandangId) ?? null;
React.useEffect(() => {
const urls = selectedKandang?.feed_in_button_urls ?? [];
setFeedInButtonUrls(urls.length > 0 ? urls.map((entry) => ({ ...entry })) : [emptyFeedInButtonUrl()]);
}, [selectedKandang?.id, selectedKandang?.feed_in_button_urls]);
const wrap = async (fn: () => Promise<void>) => {
setSaving(true);
setError(null);
@@ -234,6 +244,96 @@ const SettingsPage: React.FC = () => {
</li>
))}
</ul>
{selectedKandang && isSuperAdmin && (
<form
className="mt-4 pt-4 border-t space-y-3"
onSubmit={(e) => {
e.preventDefault();
void wrap(async () => {
const payload = feedInButtonUrls
.map((entry) => ({
label: entry.label.trim(),
url: entry.url.trim(),
}))
.filter((entry) => entry.url);
await api.kandangs.update(selectedKandang.id, {
feed_in_button_urls: payload,
});
flash(`URL Feed In/Out Button untuk ${selectedKandang.kandang_name} disimpan.`);
});
}}
>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-gray-700">URL Feed In/Out Button</p>
<p className="text-xs text-gray-500 mt-1">
Tambahkan satu atau lebih alamat hitung feed in untuk kandang ini.
</p>
</div>
{feedInButtonUrls.map((entry, index) => (
<div key={index} className="rounded-lg border border-gray-200 p-3 space-y-2">
<label className="block text-sm">
Label (opsional)
<input
className={`${inputClass} mt-1`}
placeholder="Contoh: Gudang A"
value={entry.label}
onChange={(e) =>
setFeedInButtonUrls((rows) =>
rows.map((row, rowIndex) =>
rowIndex === index ? { ...row, label: e.target.value } : row
)
)
}
/>
</label>
<label className="block text-sm">
URL
<input
className={`${inputClass} mt-1`}
type="url"
placeholder="https://..."
value={entry.url}
onChange={(e) =>
setFeedInButtonUrls((rows) =>
rows.map((row, rowIndex) =>
rowIndex === index ? { ...row, url: e.target.value } : row
)
)
}
/>
</label>
{feedInButtonUrls.length > 1 && (
<button
type="button"
className="text-xs text-red-500 hover:underline"
onClick={() =>
setFeedInButtonUrls((rows) => rows.filter((_, rowIndex) => rowIndex !== index))
}
>
Hapus URL
</button>
)}
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="border border-gray-300 text-gray-700 px-3 py-2 rounded-lg text-sm font-semibold hover:bg-gray-50"
onClick={() => setFeedInButtonUrls((rows) => [...rows, emptyFeedInButtonUrl()])}
>
Tambah URL
</button>
<button
className="bg-red-600 text-white px-3 py-2 rounded-lg text-sm font-semibold"
disabled={saving}
>
Simpan URL
</button>
</div>
</form>
)}
</section>
<section className="bg-white rounded-xl shadow-sm p-5">
<h2 className="font-semibold text-gray-800 mb-3">Flock</h2>
@@ -0,0 +1,88 @@
import React from 'react';
import type { Kandang } from '../../types/api.ts';
type FeedInButtonGridProps = {
kandangs: Kandang[];
variant?: 'default' | 'kiosk';
};
type ButtonItem = {
key: string;
kandangName: string;
label: string;
url: string;
};
const toButtonItems = (kandangs: Kandang[]): ButtonItem[] =>
kandangs.flatMap((kandang) =>
(kandang.feed_in_button_urls ?? [])
.filter((entry) => entry.url.trim())
.map((entry, index) => ({
key: `${kandang.id}-${index}`,
kandangName: kandang.kandang_name,
label: entry.label.trim(),
url: entry.url.trim(),
}))
);
const FeedInButtonGrid: React.FC<FeedInButtonGridProps> = ({ kandangs, variant = 'default' }) => {
const buttons = toButtonItems(kandangs);
const isKiosk = variant === 'kiosk';
if (buttons.length === 0) {
return (
<div
className={`rounded-xl border border-dashed border-gray-300 bg-white text-center ${
isKiosk ? 'p-10' : 'p-8'
}`}
>
<i className="fa-solid fa-link-slash text-3xl text-gray-300 mb-3" />
<p className="text-gray-600 font-medium">Belum ada URL Feed In yang dikonfigurasi.</p>
<p className="text-sm text-gray-500 mt-1">
Atur alamat per kandang di menu Pengaturan.
</p>
</div>
);
}
return (
<div
className={`grid gap-4 ${
isKiosk
? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl mx-auto'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
}`}
>
{buttons.map((button) => {
const title = button.label || button.kandangName;
const subtitle = button.label ? button.kandangName : button.url;
return (
<a
key={button.key}
href={button.url}
target="_blank"
rel="noopener noreferrer"
className={`group flex flex-col items-center justify-center rounded-2xl border-2 border-red-100 bg-white text-center shadow-sm transition-all hover:border-red-500 hover:shadow-lg hover:-translate-y-0.5 ${
isKiosk ? 'min-h-40 p-8' : 'min-h-32 p-6'
}`}
>
<span
className={`mb-3 flex items-center justify-center rounded-full bg-red-50 text-red-600 group-hover:bg-red-600 group-hover:text-white transition-colors ${
isKiosk ? 'h-16 w-16 text-3xl' : 'h-12 w-12 text-2xl'
}`}
>
<i className="fa-solid fa-arrow-up-right-from-square" />
</span>
<span className={`font-bold text-gray-800 ${isKiosk ? 'text-2xl' : 'text-lg'}`}>
{title}
</span>
<span className="mt-2 text-xs text-gray-400 truncate max-w-full px-2">{subtitle}</span>
</a>
);
})}
</div>
);
};
export default FeedInButtonGrid;
@@ -0,0 +1,149 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api, errorMessage } from '../../services/apiClient.ts';
import type { Kandang, Site } from '../../types/api.ts';
import { Banner } from '../ui/Feedback.tsx';
import FeedInButtonGrid from './FeedInButtonGrid.tsx';
type FeedInButtonPanelProps = {
variant?: 'default' | 'kiosk';
};
const hasConfiguredUrls = (kandang: Kandang): boolean =>
(kandang.feed_in_button_urls ?? []).some((entry) => entry.url.trim());
const FeedInButtonPanel: React.FC<FeedInButtonPanelProps> = ({ variant = 'default' }) => {
const [sites, setSites] = useState<Site[]>([]);
const [kandangs, setKandangs] = useState<Kandang[]>([]);
const [selectedSiteId, setSelectedSiteId] = useState<number | null>(null);
const [selectedKandangId, setSelectedKandangId] = useState<number | 'all'>('all');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isKiosk = variant === 'kiosk';
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const nextSites = await api.sites.list();
if (cancelled) return;
setSites(nextSites);
setSelectedSiteId((prev) => {
if (prev && nextSites.some((site) => site.id === prev)) return prev;
return nextSites[0]?.id ?? null;
});
} catch (err) {
if (!cancelled) setError(errorMessage(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (selectedSiteId == null) {
setKandangs([]);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const nextKandangs = await api.kandangs.list({ site_id: selectedSiteId });
if (cancelled) return;
setKandangs(nextKandangs);
setSelectedKandangId('all');
} catch (err) {
if (!cancelled) setError(errorMessage(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedSiteId]);
const filterableKandangs = useMemo(() => kandangs.filter(hasConfiguredUrls), [kandangs]);
const displayedKandangs = useMemo(() => {
if (selectedKandangId === 'all') return filterableKandangs;
return filterableKandangs.filter((kandang) => kandang.id === selectedKandangId);
}, [filterableKandangs, selectedKandangId]);
const chipClass = (active: boolean) =>
`px-4 py-2 rounded-full text-sm font-semibold border transition-colors ${
active
? 'bg-red-600 text-white border-red-600 shadow-sm'
: 'bg-white text-gray-700 border-gray-300 hover:border-red-300 hover:text-red-600'
}`;
return (
<div className={isKiosk ? 'max-w-5xl mx-auto space-y-6' : 'space-y-6'}>
{error && <Banner kind="error">{error}</Banner>}
{(sites.length > 1 || filterableKandangs.length > 0) && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 space-y-4">
{sites.length > 1 && (
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<label className="text-sm font-medium text-gray-700 shrink-0" htmlFor="feed-in-site">
Site
</label>
<select
id="feed-in-site"
value={selectedSiteId ?? ''}
onChange={(e) => setSelectedSiteId(e.target.value ? Number(e.target.value) : null)}
className="border border-gray-300 rounded-md px-3 py-2 text-sm w-full sm:max-w-xs focus:ring-red-500 focus:border-red-500"
>
{sites.map((site) => (
<option key={site.id} value={site.id}>
{site.site_name}
</option>
))}
</select>
</div>
)}
{filterableKandangs.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Kandang</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={chipClass(selectedKandangId === 'all')}
onClick={() => setSelectedKandangId('all')}
>
Semua
</button>
{filterableKandangs.map((kandang) => (
<button
key={kandang.id}
type="button"
className={chipClass(selectedKandangId === kandang.id)}
onClick={() => setSelectedKandangId(kandang.id)}
>
{kandang.kandang_name}
</button>
))}
</div>
</div>
)}
</div>
)}
{loading ? (
<div className="text-center text-sm text-gray-500 py-10">Memuat kandang...</div>
) : (
<FeedInButtonGrid kandangs={displayedKandangs} variant={variant} />
)}
</div>
);
};
export default FeedInButtonPanel;
+35 -20
View File
@@ -6,6 +6,7 @@ export interface NavItem {
label: string;
icon: React.ReactNode;
permissionId?: string;
href?: string;
subItems?: NavItem[];
disabled?: boolean;
}
@@ -60,26 +61,40 @@ const NavLink: React.FC<NavLinkProps> = ({ item, isActive, isCollapsed, setView,
{!isCollapsed && isOpen && (
<ul className="pl-8 mt-1 space-y-1">
{item.subItems.map((subItem) => (
<li key={subItem.view}>
<button
onClick={() => !subItem.disabled && setView(subItem.view)}
disabled={subItem.disabled}
className={`w-full flex items-center h-9 pl-3 pr-2 rounded-md transition-colors duration-200 group text-left ${
subItem.disabled
? 'opacity-40 cursor-not-allowed text-gray-400'
: view === subItem.view
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
}`}
>
<div className="w-6 text-base flex items-center justify-center -ml-1">
{subItem.icon}
</div>
<span className="ml-2 text-sm font-medium whitespace-nowrap">
{subItem.label}
</span>
{subItem.disabled && <i className="fa-solid fa-lock ml-auto text-xs"></i>}
</button>
<li key={subItem.href ?? subItem.view}>
{subItem.href ? (
<a
href={subItem.href}
className={`w-full flex items-center h-9 pl-3 pr-2 rounded-md transition-colors duration-200 group text-left text-gray-500 hover:bg-gray-100 hover:text-gray-800`}
>
<div className="w-6 text-base flex items-center justify-center -ml-1">
{subItem.icon}
</div>
<span className="ml-2 text-sm font-medium whitespace-nowrap">
{subItem.label}
</span>
</a>
) : (
<button
onClick={() => !subItem.disabled && setView(subItem.view)}
disabled={subItem.disabled}
className={`w-full flex items-center h-9 pl-3 pr-2 rounded-md transition-colors duration-200 group text-left ${
subItem.disabled
? 'opacity-40 cursor-not-allowed text-gray-400'
: view === subItem.view
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800'
}`}
>
<div className="w-6 text-base flex items-center justify-center -ml-1">
{subItem.icon}
</div>
<span className="ml-2 text-sm font-medium whitespace-nowrap">
{subItem.label}
</span>
{subItem.disabled && <i className="fa-solid fa-lock ml-auto text-xs"></i>}
</button>
)}
</li>
))}
</ul>
+8
View File
@@ -13,6 +13,14 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
location = /button {
return 301 /button/;
}
location /button/ {
try_files $uri $uri/ /button/index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
+17 -5
View File
@@ -55,7 +55,12 @@ function getCookie(name: string): string | null {
function parseDetail(body: unknown): string {
if (!body || typeof body !== 'object') return 'Request failed';
const detail = (body as { detail?: unknown }).detail;
if (typeof detail === 'string') return detail;
if (typeof detail === 'string') {
if (detail.toLowerCase().includes('csrf')) {
return 'Permintaan ditolak (CSRF). Muat ulang halaman lalu coba lagi.';
}
return detail;
}
if (Array.isArray(detail)) return detail.map(String).join(', ');
const entries = Object.entries(body as Record<string, unknown>)
.filter(([key]) => key !== 'detail')
@@ -64,8 +69,6 @@ function parseDetail(body: unknown): string {
}
async function ensureCsrf(): Promise<string> {
const existing = getCookie('csrftoken');
if (existing) return existing;
const res = await fetch(`${API_BASE}/auth/csrf/`, { credentials: 'include' });
if (!res.ok) {
throw new ApiError('Gagal mengambil CSRF token', res.status, null);
@@ -74,6 +77,12 @@ async function ensureCsrf(): Promise<string> {
return data.csrfToken || getCookie('csrftoken') || '';
}
function isCsrfFailure(status: number, body: unknown): boolean {
if (status !== 403) return false;
const detail = parseDetail(body).toLowerCase();
return detail.includes('csrf');
}
type Query = Record<string, string | number | boolean | null | undefined>;
function withQuery(path: string, query?: Query): string {
@@ -87,7 +96,7 @@ function withQuery(path: string, query?: Query): string {
return qs ? `${path}?${qs}` : path;
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
async function request<T>(path: string, init: RequestInit = {}, csrfRetried = false): Promise<T> {
const method = (init.method || 'GET').toUpperCase();
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
@@ -121,7 +130,10 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
}
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
if (isCsrfFailure(res.status, body) && !csrfRetried && method !== 'GET' && method !== 'HEAD') {
return request<T>(path, init, true);
}
if (res.status === 401) {
window.dispatchEvent(new CustomEvent(AUTH_UNAUTHORIZED_EVENT));
}
throw new ApiError(parseDetail(body), res.status, body);
+7
View File
@@ -23,9 +23,15 @@ export type Site = {
updated_at: string;
};
export type FeedInButtonUrl = {
label: string;
url: string;
};
export type Kandang = {
id: number;
kandang_name: string;
feed_in_button_urls: FeedInButtonUrl[];
site: number;
created_at: string;
updated_at: string;
@@ -219,6 +225,7 @@ export type SiteWrite = { site_name: string; user: number };
export type KandangWrite = {
kandang_name: string;
site: number;
feed_in_button_urls?: FeedInButtonUrl[];
};
export type FlockWrite = {
flock_name: string;
+34 -1
View File
@@ -1,15 +1,48 @@
/// <reference types="vitest/config" />
import path from 'path';
import type { Plugin } from 'vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const buttonRoutePlugin = (): Plugin => ({
name: 'button-route',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url ?? '';
const [pathname, search = ''] = url.split('?');
const query = search ? `?${search}` : '';
if (pathname === '/button') {
res.statusCode = 301;
res.setHeader('Location', `/button/${query}`);
res.end();
return;
}
if (pathname === '/button/' || pathname === '/button/index.html') {
req.url = `/button/index.html${query}`;
}
next();
});
},
});
export default defineConfig({
plugins: [react()],
plugins: [react(), buttonRoutePlugin()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
build: {
rollupOptions: {
input: {
main: path.resolve(__dirname, 'index.html'),
button: path.resolve(__dirname, 'button/index.html'),
},
},
},
server: {
port: 3001,
host: '0.0.0.0',