PocketBase について調べると、
だいたい同じ説明に行き当たります。
「Go の単一バイナリで動く」
「軽量な Firebase 代替」
「ローカルで完結する BaaS」
どれも事実ですが、
それだけでは PocketBase の価値は見えてきません。
今回触りたいのは、
あまり語られていない 「File の扱いやすさ」です。
ファイルが
単なる置き場ではなく、
文脈を持ったデータとして自然に扱えること。
単一HTMLから API を叩き、
ファイル付きの簡単な管理UIを作りながら、
その感触を確かめていきます。
完成度の高いツールを作るのが目的ではありません。
「なぜ、これが成立するのか」を理解するための Hands-Onです。
今回作るもの(完成イメージ)
今回のHands-Onで作るのは、とても小さなファイル管理UIです。
いわゆる「業務システム」ではありません。
承認フローも、締切管理も、権限の枝分かれもありません。
やることは、これだけです。
- PocketBase のユーザーでログインする
- 自分の報告書一覧を見る
- テキストとファイルを添えて、新しい報告書を作る
- 添付ファイルをダウンロードする
対象ユーザーは 自分ひとり。
まずは「1人で使って成立するか」を確かめます。
画面構成もシンプルです。
- 上部にログイン状態の表示
- 入力フォーム(タイトル/メモ/ファイル)
- 下に一覧テーブル
CSSも最小限。
「見栄え」より「何をしているかが分かる」ことを優先しています。
なぜ「報告書管理」なのか
この題材を選んだ理由は単純です。
- ファイルが主役になる
- テキストとファイルを一緒に扱う
- 業務でも日常でも想像しやすい
そして何より、
PocketBase の File 機能を自然に使えるからです。
単なる ToDo やメモでは、
File フィールドの価値が見えにくい。
今回はあえて、
「ファイルが“ただの置き場”ではなく、
文脈を持ったデータになる」
その感触を確かめることを目的にしています。
ゴールは「完成」ではない
ここで大事な前提をはっきりさせておきます。
このHands-Onのゴールは、
- 立派なアプリを作ること
- ベストプラクティスを網羅すること
ではありません。
「単一HTMLでも、ここまで実用になる」
それを確認することがゴールです。
途中で感じた違和感や引っかかりも、
そのまま書いていきます。
それらは失敗ではなく、
設計を決めるための情報だからです。

なぜ「単一HTML」で作るのか
今回のHands-Onでは、ビルド環境を一切使いません。
- npm なし
- bundler なし
- framework CLI なし
index.html と report.js の2ファイルだけです。
これは手抜きではありません。
意図的な選択です。
初心者がつまずくポイントを減らす
PocketBase や Alpine.js を初めて触る人にとって、
一番のハードルは「コード」ではなく 準備です。
- Node.js を入れる
- npm install をする
- 設定ファイルが増える
- 何がどこで動いているのか分からなくなる
これだけで、手が止まります。
今回やりたいのは、
PocketBase の API が
どういう形で“すでに使える”のか
それを 最短距離で体験すること。
単一HTMLなら、ブラウザで開いて
JS を読めば、すべてが見えます。
PocketBase は「APIが最初から生えている」
PocketBase の大きな特徴は、
- 認証
- CRUD
- ファイルアップロード
- 管理UI
これらが 最初から揃っていることです。
つまり、
- APIサーバを書く必要がない
- SDKを使わなくても動く
REST API をそのまま叩くだけで、
すでに“バックエンドが完成している”。
この強みは、
単一HTML構成と非常に相性がいい。
Alpine.js を使う理由(この時点での話)
この段階では、Alpine.js を
「小さなUIの補助」として使います。
- 状態をHTMLに結びつける
- イベントを簡単に書く
- フレームワークほど重くならない
React や Vue を否定する意図はありません。
今回は、
UIは最小
APIは本物
という構成を試したい。
Alpine.js は、その条件にちょうど収まります。
単一HTMLでも「実用」になるか?
よくある疑問です。
- 単一HTMLなんて、サンプル止まりでは?
- 実務では無理では?
今回のHands-Onは、
その疑問に対する 実測 です。
- ログインできる
- ファイルを扱える
- 権限も分かれる
- 管理UIは別で存在する
この条件が揃えば、
思った以上に“使える”。
もちろん、
大規模開発には向きません。
でも、
- 個人用
- 小規模チーム
- 内製ツール
なら、十分に成立します。
PocketBase 側の準備(最小構成)
基本的な設定の仕方は、前回の PockeBase の Hands-ON 記事にまとめていますから、
そちらをまず参照して下さい。
ここでは PocketBase をどう設計したか を説明します。
難しいことはしません。
「このHands-Onが成立するために必要な最低限」だけです。
今回のゴールを先に決める
このUIでやりたいことは、実はシンプルです。
- ログインできる
- ファイル付きのレコードを登録できる
- 自分の投稿だけが見える
- 一覧表示できる
これを満たすために必要なものは、驚くほど少ない。
コレクション構成
使うのは 2つだけ です。
1. users(組み込み)
これは PocketBase 標準の users コレクションです。
- 自分で作らない
- 使い回す
- 1人しかいなくても気にしない
Hands-On 段階では
「認証が動くか」だけ分かれば十分。
2. reports(自作)
今回の主役です。
最低限、こんなフィールドを持たせます。
- title(text, required)
- note(text)
- attachment(file, optional)
- status(bool)
- owner(relation → users)
ポイントは owner(relation) です。
![PocketBase「報告書管理」Hands-ONのための Collections 定義 [ reposrts ]](https://b.aries67.com/wp-content/uploads/2025/12/image-33.webp)
relation は「どこで張るか」
これは迷いやすいですが、答えは単純です。
- reports 側に張る
- users には何も追加しない
reports.owner → users.id
これだけ。
PocketBase は relation を
「参照側にだけ持たせる」設計が基本です。

access rule(ここが肝)
今回、一番大事なのはここです。
list / view / create / update / delete
すべて、同じ考え方で統一します。
@request.auth.id != "" && owner = @request.auth.id
意味はそのまま。
- ログインしていること
- 自分のレコードであること
これだけで、
- 他人のデータは見えない
- 勝手に編集できない
という状態が完成します。
![PocketBase「報告書管理」Hands-ONのための API Rules 定義 [ reposrts ]](https://b.aries67.com/wp-content/uploads/2025/12/image-35-986x1024.webp)
title に required を付ける理由
Hands-On では、
「失敗の形」が見えること が大事です。
title を必須にしておくと、
- UIで空欄 → エラー
- APIで弾かれる → 挙動が分かる
PocketBase が
どこでチェックしているのかが
自然に理解できます。
status は bool でいいのか?
はい、今回は bool で十分 です。
- 下書き / 提出済み
- true / false
運用を複雑にしない。
これは「自分用UI」なので、
最初から完璧な業務設計は不要です。
正直ポイント①
users が1人しかいない問題
あります。普通に。
でも問題ありません。
- 設計として成立しているか
- 権限が正しく効いているか
を見るのが目的だからです。
ここを「将来複数人になる前提」で
無理に複雑にすると、
Hands-On の価値が落ちます。
この状態で、
PocketBase 側の準備は完了です。
フロント側の構成(index.html と report.js)
今回のフロントは、たった2ファイルです。※コードは末尾に掲載しています
index.html(画面)report.js(ロジック)
そして読み込みはこの順番にします。
- report.js(自作)
- Alpine.js(CDN)
<script defer src="./report.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</body>
</html>
HTML側でやるのは 表示と入力。
JS側でやるのは APIを叩くこと。
この役割分担にしておくと、初心者でも追いやすいです。
index.html の役割
index.html は「UIの枠」です。
- 入力フォーム
- 一覧テーブル
- 状態表示(ログイン済み/未ログイン)
- ボタン配置(ログイン、作成、同期)
ここでのポイントは、
HTMLにロジックを書きすぎないこと。
Alpine を使うと、つい HTML 側に処理を書けますが、
今回の目的は「APIの動きが分かること」なので、
- クリックでメソッドを呼ぶ
- 表示に値をバインドする
それ以上は report.js に寄せます。
report.js の役割
report.js は「APIクライアント」です。
最低限、以下を持ちます。
- 設定(PocketBase URL)
- auth 状態(token / userId)
- ログ出力(画面に履歴)
- API 呼び出し関数(GET/POST)
- 主要機能
- login
- logout
- listReports
- createReport(file upload含む)
ここを分ける理由は、単純です。
仕様や挙動で迷ったら
report.js を見れば“どこを叩いてるか”が分かる
Hands-On はこれが大事。
PocketBase URL をUIで切り替えられるようにする
初心者が詰まりやすいのがここです。
- 127.0.0.1 なのか
- LAN IP なのか
- https が必要なのか
この辺で混乱すると、以降の検証が止まります。
だから今回は、
pbUrlを入力欄で持つ- localStorage に保存する
という「現場仕様」にしました。
“更新(同期)”ボタンの位置づけ
作った直後は、これが謎になりがちです。
でも単一HTML構成では、
このボタンは意味があります。
- 作成後に一覧を取り直す
- PB管理画面で変更した後に反映する
- Rules調整中の挙動確認をする
つまり、これは F5 をUIにしたものです。
リアルタイム購読を入れれば不要ですが、
今回は “省エネHands-On” なので、これで十分。
正直ポイント②
初期化(init)の扱いで一度つまずく
Alpine は init() を自動で呼びます。
ここを知らないと、
x-init="init()"を付けてしまうinit()が2回走る
という現象が起きます。
今回も最初にそれを踏みました。
なので最終形は、こう割り切りました。
x-initは使わないinit()は report.js 側のAlpine.dataに置く
これで初期化は一度だけになり、落ち着きます。
次章では、いよいよ
- PocketBase へログインする
- token を保持する
という 認証パートに入ります。
PocketBase にログインする(auth の最小構成)
まず強調しておきます。
今回のログインは
「サービスとして正しい認証設計」ではありません。
あくまで、
- 自分用
- 検証用
- 管理画面の延長
という位置づけです。
この割り切りが、初心者にはむしろ安心材料になります。
users コレクションを使う理由
PocketBase には
- users(認証用)
- 通常のコレクション
があります。
今回は users をそのまま使います。
理由は単純で、
- 認証 API が最初から揃っている
- トークン管理を PB に任せられる
- Rules の書き方が分かりやすい
「認証を自作しない」
これだけで Hands-On の難易度は一段下がります。
ログインの API
叩いているのはこれだけです。
POST /api/collections/users/auth-with-password
送るのは、
- email(または username)
- password
返ってくるのは、
- token
- record(ユーザー情報)
これをそのまま受け取ります。
token の扱い(重要)
token は次の3点で使います。
- fetch の Authorization ヘッダ
- localStorage に保存
- 画面の表示切り替え
headers.set("Authorization", `Bearer ${token}`);
この1行で、以降の API はすべて通ります。
ログイン状態の復元(restore session)
Hands-On で必ず入れておきたいのが、これです。
- ページリロード
- ブラウザ再起動
これでログインが消えると、
「動いてたのに壊れた」と感じがちです。
なので今回は、
- token が localStorage にあれば
- ログイン済みとして扱う
というシンプルな復元を入れました。
正直に言うと、
「token の有効期限チェックはしていない」
でも大丈夫です。
これは 学習用UI です。
表示切り替え(x-if を使う理由)
ログイン状態の表示は、
<!-- 該当箇所:ログイン状態の表示切り替え -->
<span :class="auth.token ? 'ok' : 'ng'">
<template x-if="auth.token">
ログイン済み
</template>
<template x-if="!auth.token">
未ログイン
</template>
</span>
これで十分です。
途中で一度、
Cannot set properties of nullx-if が暴れる
という挙動に当たりましたが、
これは Alpine 初学者あるあるです。
原因は、
- 初期化タイミング
- DOM の再評価
この Hands-On では、
- 初期化を一度だけにする
- 状態は app 内で完結させる
ことで静かに収まりました。
正直ポイント③
「ログイン失敗した?」は大体“自分の操作”
今回もありました。
- ログイン
- ログアウト
- 再ログイン
をテストしていただけ。
でもログを見ると、
login ok
logout
login ok
ちゃんと動いている。
この「勘違いしやすさ」も含めて、
Hands-On では ログ表示が命です。
次章では、
- reports コレクションの一覧取得
- 「owner は relation ID で十分」という話
に進みます。
reports 一覧を取得する(REST を直叩きする気持ちよさ)
PocketBase の良さは、ここに集約されます。
- DBを触っていないのに
- APIサーバを書いていないのに
- もう一覧取得できる
フロントから叩くのは、これだけです。
GET /api/collections/reports/records
一覧取得でやることは2つだけ
1) token を付けて叩く
ログイン後の API は、基本これ。
headers.set("Authorization", `Bearer ${token}`);
2) “自分の分だけ”に絞る
今回の設計だと、reports には owner が入っています。
だから filter はこう書けます。
filter=owner="<userId>"
例:
GET /api/collections/reports/records?filter=owner="xxxx"&sort=-created
sort=-created を付けると、新しい順。
“Rules で絞っているのに filter も書くの?”
初心者が引っかかるポイントなので、先に言っておきます。
- Rules はセキュリティ
- filter は表示の都合(クエリ)
Rules だけでも、他人の分は見えません。
でも filter を入れないと、PBは内部で許可分を返すだけなので、意図が読みづらい。
Hands-On では、
- Rulesで守る
- filterで意図を明示する
この二段構えが理解しやすいです。
レスポンスはこうなる(見慣れた形)
PocketBase の list は、だいたいこの形です。
{
"page": 1,
"perPage": 30,
"totalPages": 1,
"totalItems": 2,
"items": [
{
"id": "...",
"title": "...",
"note": "...",
"status": true,
"attachment": "filename.jpg",
"owner": "RELATION_RECORD_ID",
"created": "...",
"updated": "..."
}
]
}
ここで重要なのが owner。
relation は “展開しない” で成立する
初心者がやりがちなのが、
- users の情報(メールとか)も画面に出したくなる
- expand を使いたくなる
でも今回の UI では、owner は ID で十分です。
なぜなら、目的は「自分のファイル付き報告書を管理する」だから。
- owner を表示する必要がない
- 自分だけが使う
- 認証済みなら自明
つまり relation は「参照として持っていれば勝ち」です。
更新(同期)ボタンが効く瞬間
一覧取得は、
- 作成後に再取得
- 管理画面で編集したあとに反映
- Rules調整中に挙動確認
このときに効きます。
単一HTML構成では、リアルタイム購読を入れない代わりに
list を取り直すだけで十分な操作感になります。
正直ポイント④
一覧取得が2回走ったら、まず初期化を疑う
Hands-On 中に list ok items=1 が2回出たら、
init()が2回走っているx-initを重ねた- x-data が複数ある
だいたいこのどれかです。
PocketBase は悪くない。
フロント初期化の問題です。
(今回もそこを整理して、1回に落ち着きました。)
次章は、いよいよ本丸。
- 新規作成(title/note/status)
- そして file upload(FormData)
ここをやります。
reports を作成する(FormData で file を送る)
ここで一気に現実感が出ます。
- テキストだけじゃない
- ファイルがある
- でも難しいことはしていない
PocketBase は、この辺が本当に素直です。
なぜ JSON ではなく FormData なのか
結論から言います。
file フィールドが1つでもあったら、FormData 一択
理由は単純で、
multipart/form-dataが必要- JSON ではファイルを送れない
- PocketBase 側がそのまま受けてくれる
「難しそう」に見えますが、
ブラウザの FormData をそのまま使うだけです。
作成時に送るフィールド
今回の reports は、最小構成です。
- title(必須)
- note(任意)
- status(Boolean)
- attachment(file)
- owner(user id)
Rules 側で、
@request.auth.id != "" && title != ""
と定義してあるので、
- ログイン必須
- title 空は禁止
という最低限の制約がかかっています。
FormData の作り方(実際の感覚)
const fd = new FormData();
fd.append("title", form.title);
fd.append("note", form.note || "");
fd.append("status", true);
fd.append("owner", auth.userId);
if (fileInput.files[0]) {
fd.append("attachment", fileInput.files[0]);
}
ポイントは3つ。
- Content-Type を自分で指定しない
- file はあれば append、なければ送らない
- Boolean もそのまま送ってOK
fetch 側はこれだけ。
fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: fd,
});
JSON のときより、むしろ短いです。
ファイルが保存される場所
アップロードされたファイルは、
/api/files/{collectionId}/{recordId}/{filename}
に自動で配置されます。
なので一覧では、
<template x-if="r.attachment">
<a :href="fileUrl(r)" target="_blank">download</a>
</template>
これだけで成立します。
ストレージ設定も、署名URLも不要。
正直ポイント⑤
「file upload のコード、意外と短い」
Hands-On をやってみると分かります。
- Firebase Storage より楽
- 署名URL不要
- SDK不要
PocketBase は
「ローカルで動く管理画面付きバックエンド」
という思想が、ここで一気に効いてきます。
まとめ ── この構成が「ちょうどいい」理由
今回やったことを、もう一度並べると驚くほどシンプルです。
- PocketBase を起動した
- reports コレクションを作った
- users をそのまま使った
- 単一 HTML から REST API を叩いた
- FormData で file を送った
- Alpine.js で最低限の UI を付けた
以上。
ビルドも、SDK も、複雑な設定もありません。
なぜ「単一HTML」で成立したのか
理由は3つあります。
- PocketBase が API + 認証 + ファイル + 管理UI を全部持っている
- Alpine.js が「DOMに近い場所」で自然に書ける
- 今回は「状態管理」を欲張らなかった
特に大きいのは、
APIが最初から“生えている”
という点です。
フロントエンド側は
「どう叩くか」を考えるだけで済みました。
Alpine.js を“主役にしなかった理由
この記事、Alpine.js の説明は最小限です。
意図的です。
- Alpine は軽い
- 学習コストが低い
- でも思想を語り始めると長くなる
今回は、
「Vanilla JS だと辛い人の逃げ道」
として使っています。
Alpine.js を覚える記事ではなく、
PocketBase の“触感”を掴む記事だからです。
この構成が向いている人
正直に言います。
この構成が刺さるのは、こういう人です。
- Firebase は重すぎると感じている
- ビルド環境を増やしたくない
- 小さな業務ツールを素早く作りたい
- ファイルが主役の業務を抱えている
- 「まず動くもの」を優先したい
逆に、
- 大規模SPA
- 複雑な権限階層
- 大量トラフィック前提
こういう用途には向きません。
正直ポイントまとめ
この記事で、あえて隠さなかった点です。
- status は雑に true にしている
- 自分用ツール前提の設計
- UIは最低限
- ログイン周りも簡素
- x-if 周りで Alpine の癖に一度つまずいた
でも、それでいい。
Hands-On は「完成品のデモ」ではなく、
「現場でどう転ぶかの記録」です。
最後に
PocketBase は、
小さく作る人に、ちゃんと優しい
という珍しい立ち位置のツールです。
今回のように、
- 単一HTML
- File付き
- 認証あり
- 管理画面あり
ここまでを1日で触れるのは、正直かなり強い。
シリーズ化はしません。
でも、また「実戦ネタ」が出たら書きます。
それくらいの距離感が、このツールには合っています。
完成コードについて
本文では、構成理解を優先するため
コードは必要最小限の断片のみを掲載しています。
実際に動作確認した 完成版のコード一式 です。
※ 学習用途を想定しており、
エラーハンドリングやUIは最小限です。
index.html
<!-- index.html -->
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>PocketBase Reports (Alpine.js / Single HTML)</title>
<style>
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 16px;
line-height: 1.6;
background: #fafafa;
color: #222;
}
/* layout */
.row {
display: flex;
gap: 24px;
flex-wrap: wrap;
align-items: center;
}
.card {
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 12px;
padding: 14px;
margin: 14px 0;
}
/* form */
input[type="text"],
input[type="password"],
textarea,
input[type="file"] {
width: 100%;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
input[type="text"]:focus,
input[type="password"]:focus,
textarea:focus {
outline: none;
border-color: #7aa7ff;
background: #fff;
}
textarea {
min-height: 80px;
resize: vertical;
}
button {
padding: 8px 14px;
border-radius: 6px;
border: 1px solid #ddd;
background: #f5f5f5;
cursor: pointer;
}
button:hover {
background: #eee;
}
/* states */
.ok { color: #0a7; font-weight: 500; }
.ng { color: #c22; font-weight: 500; }
.muted {
color: #666;
font-size: 12px;
}
/* table */
table {
width: 100%;
border-collapse: collapse;
background: #fff;
}
th {
text-align: left;
font-size: 12px;
color: #666;
font-weight: 500;
border-bottom: 1px solid #e5e5e5;
padding: 8px;
}
td {
border-bottom: 1px solid #eee;
padding: 8px;
vertical-align: top;
}
tr:hover td {
background: #fafafa;
}
.right { text-align: right; }
/* small UI parts */
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid #ddd;
font-size: 12px;
background: #f9f9f9;
}
</style>
<style>[x-cloak]{display:none!important}</style>
</head>
<body>
<h1>PocketBase “報告書管理” 最小UI</h1>
<p class="muted">単一HTML + Alpine.js + PocketBase REST API(SDKなし)</p>
<div class="card" x-data="app">
<div class="row">
<div style="flex: 1; min-width: 280px;">
<label class="muted">PocketBase URL</label>
<input type="text" x-model="cfg.pbUrl" placeholder="http://127.0.0.1:8090" />
<div class="muted">例: http://127.0.0.1:8090(末尾スラッシュ不要)</div>
</div>
<div style="flex: 1; min-width: 280px;">
<label class="muted">Auth status</label>
<div>
<span :class="auth.token ? 'ok' : 'ng'">
<span x-show="auth.token" x-cloak>ログイン済み</span>
<span x-show="!auth.token" x-cloak>未ログイン</span>
</span>
<span class="muted" x-show="auth.userId"> / userId: <span x-text="auth.userId"></span></span>
</div>
</div>
</div>
<!-- Login -->
<div class="card">
<h2>ログイン</h2>
<div class="row">
<div style="flex: 1; min-width: 240px;">
<label class="muted">Email</label>
<input type="text" x-model="loginForm.email" autocomplete="username" />
</div>
<div style="flex: 1; min-width: 240px;">
<label class="muted">Password</label>
<input type="password" x-model="loginForm.password" autocomplete="current-password" />
</div>
</div>
<div class="row" style="margin-top:10px;">
<button @click="doLogin()" :disabled="busy.login">Login</button>
<button @click="doLogout()" :disabled="!auth.token">Logout</button>
<span class="muted" x-show="busy.login">処理中...</span>
</div>
<div class="ng" x-show="err.login" x-text="err.login"></div>
</div>
<!-- Create -->
<div class="card" x-show="auth.token">
<h2>新規作成</h2>
<div class="row">
<div style="flex: 1; min-width: 280px;">
<label class="muted">Title(必須)</label>
<input type="text" x-model="createForm.title" placeholder="例)12月 月報" />
</div>
<div style="flex: 1; min-width: 280px;">
<label class="muted">Attachment(任意)</label>
<input type="file" @change="onFilePicked($event)" />
<div class="muted" x-show="createForm.fileName">選択: <span x-text="createForm.fileName"></span></div>
</div>
</div>
<div style="margin-top:10px;">
<label class="muted">Note(任意)</label>
<textarea x-model="createForm.note" placeholder="補足メモ"></textarea>
</div>
<div class="row" style="margin-top:10px; align-items:center;">
<label class="muted">
<input type="checkbox" x-model="createForm.status" />
提出済み(status=true)
</label>
<button @click="createReport()" :disabled="busy.create">作成</button>
<span class="muted" x-show="busy.create">アップロード中...</span>
</div>
<div class="ng" x-show="err.create" x-text="err.create"></div>
</div>
<!-- List -->
<div class="card" x-show="auth.token">
<div class="row" style="align-items:center; justify-content:space-between;">
<h2 style="margin:0;">自分の報告書一覧</h2>
<div class="row" style="align-items:center;">
<button @click="refresh()" :disabled="busy.list">再読み込み</button>
<span class="muted" x-show="busy.list">取得中...</span>
</div>
</div>
<div class="ng" x-show="err.list" x-text="err.list"></div>
<table>
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Attachment</th>
<th class="right">Action</th>
</tr>
</thead>
<tbody>
<template x-for="r in reports" :key="r.id">
<tr>
<td>
<div x-text="r.title"></div>
<div class="muted">
<span x-text="r.created"></span>
</div>
<div class="muted" x-text="r.note"></div>
</td>
<td>
<span class="pill" x-text="r.status ? 'submitted' : 'draft'"></span>
</td>
<td>
<a x-show="r.attachment" x-cloak
:href="fileUrl(r)" target="_blank" rel="noreferrer">download</a>
<span x-show="!r.attachment" x-cloak class="muted">なし</span>
</td>
<td class="right">
<button @click="toggleStatus(r)" :disabled="busy.updateId === r.id">
status切替
</button>
<button @click="deleteReport(r)" :disabled="busy.deleteId === r.id">
削除
</button>
</td>
</tr>
</template>
</tbody>
</table>
<div class="muted" style="margin-top:8px;">
件数: <span x-text="reports.length"></span>
</div>
</div>
<!-- Log -->
<div class="card">
<h2>Log</h2>
<pre class="muted" style="white-space:pre-wrap;" x-text="logText"></pre>
</div>
</div>
<script defer src="./report.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</body>
</html>
report.js
// report.js
console.log('REPORT_JS_LOADED', new Date().toISOString());
const LS_PB_URL = "pb_url";
const LS_TOKEN = "pb_token";
const LS_USERID = "pb_userid";
function safeJsonParse(text) {
try { return JSON.parse(text); } catch { return null; }
}
function clampStr(s, n = 4000) {
s = String(s ?? "");
return s.length > n ? s.slice(0, n) + "…" : s;
}
async function fetchJson(url, opts = {}) {
const res = await fetch(url, opts);
const text = await res.text();
const data = safeJsonParse(text) ?? { raw: text };
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
err.data = data;
throw err;
}
return data;
}
document.addEventListener('alpine:init', () => {
if (window.__APP_REGISTERED__) return;
window.__APP_REGISTERED__ = true;
Alpine.data('app', () => ({
cfg: {
pbUrl: localStorage.getItem(LS_PB_URL) || "http://127.0.0.1:8090",
collection: "reports",
},
auth: {
token: localStorage.getItem(LS_TOKEN) || "",
userId: localStorage.getItem(LS_USERID) || "",
},
loginForm: { email: "", password: "" },
createForm: { title: "", note: "", status: false, file: null, fileName: "" },
reports: [],
busy: { login: false, list: false, create: false, updateId: "", deleteId: "" },
err: { login: "", list: "", create: "" },
logText: "",
log(...args) {
const line = args.map(a => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
this.logText = clampStr(line + "\n" + this.logText, 8000);
},
pbBase() {
return String(this.cfg.pbUrl || "").replace(/\/$/, "");
},
headersJson() {
const h = new Headers();
h.set("Content-Type", "application/json");
if (this.auth.token) h.set("Authorization", `Bearer ${this.auth.token}`);
return h;
},
headersAuthOnly() {
const h = new Headers();
if (this.auth.token) h.set("Authorization", `Bearer ${this.auth.token}`);
return h;
},
async init() {
console.log('app init ok');
// pbUrl 永続化
this.$watch("cfg.pbUrl", (v) => localStorage.setItem(LS_PB_URL, String(v || "")));
// token があれば一覧取得して生存確認
if (this.auth.token) {
this.log("restore session: token exists");
await this.refresh().catch(() => {});
}
},
// ----- Auth -----
async doLogin() {
this.err.login = "";
this.busy.login = true;
try {
const email = String(this.loginForm.email || "").trim();
const password = String(this.loginForm.password || "");
if (!email || !password) throw new Error("email / password を入れてください");
const url = `${this.pbBase()}/api/collections/users/auth-with-password`;
const body = JSON.stringify({ identity: email, password });
const data = await fetchJson(url, {
method: "POST",
headers: new Headers({ "Content-Type": "application/json" }),
body,
});
this.auth.token = data.token || "";
this.auth.userId = data.record?.id || "";
localStorage.setItem(LS_TOKEN, this.auth.token);
localStorage.setItem(LS_USERID, this.auth.userId);
this.log("login ok", this.auth.userId);
await this.refresh();
} catch (e) {
this.err.login = e?.data?.message || e?.message || "login failed";
this.log("login err", e?.status || "", e?.data || e?.message || "");
} finally {
this.busy.login = false;
}
},
doLogout() {
this.auth.token = "";
this.auth.userId = "";
localStorage.removeItem(LS_TOKEN);
localStorage.removeItem(LS_USERID);
this.reports = [];
this.log("logout");
},
// ----- File picker -----
onFilePicked(ev) {
const f = ev?.target?.files?.[0] || null;
this.createForm.file = f;
this.createForm.fileName = f ? f.name : "";
this.log("file picked", this.createForm.fileName || "(none)");
},
// ----- CRUD -----
async refresh() {
return await this.listReports();
},
async listReports() {
this.err.list = "";
this.busy.list = true;
try {
if (!this.auth.token) throw new Error("先にログインしてください");
const params = new URLSearchParams({
page: "1",
perPage: "50",
sort: "-created",
});
const url = `${this.pbBase()}/api/collections/${encodeURIComponent(this.cfg.collection)}/records?${params.toString()}`;
const data = await fetchJson(url, { method: "GET", headers: this.headersAuthOnly() });
this.reports = (data.items || []).map(r => ({
...r,
note: clampStr(r.note || "", 500),
}));
this.log("list ok", `items=${this.reports.length}`);
} catch (e) {
this.err.list = e?.data?.message || e?.message || "list failed";
this.log("list err", e?.status || "", e?.data || e?.message || "");
if (e?.status === 401) {
this.log("token invalid -> logout");
this.doLogout();
}
} finally {
this.busy.list = false;
}
},
async createReport() {
this.err.create = "";
this.busy.create = true;
try {
if (!this.auth.token) throw new Error("先にログインしてください");
if (!this.auth.userId) throw new Error("userId が取れていません(ログインし直してください)");
const title = String(this.createForm.title || "").trim();
const note = String(this.createForm.note || "").trim();
const status = !!this.createForm.status;
const file = this.createForm.file;
if (!title) throw new Error("title は必須です");
const fd = new FormData();
fd.append("title", title);
fd.append("note", note);
fd.append("status", String(status)); // PB側 bool
fd.append("owner", this.auth.userId); // relation id
if (file) fd.append("attachment", file);
const url = `${this.pbBase()}/api/collections/${encodeURIComponent(this.cfg.collection)}/records`;
const data = await fetchJson(url, { method: "POST", headers: this.headersAuthOnly(), body: fd });
this.log("create ok", data.id);
// reset
this.createForm.title = "";
this.createForm.note = "";
this.createForm.status = false;
this.createForm.file = null;
this.createForm.fileName = "";
await this.refresh();
} catch (e) {
this.err.create = e?.data?.message || e?.message || "create failed";
this.log("create err", e?.status || "", e?.data || e?.message || "");
} finally {
this.busy.create = false;
}
},
async toggleStatus(r) {
this.busy.updateId = r.id;
try {
const url = `${this.pbBase()}/api/collections/${encodeURIComponent(this.cfg.collection)}/records/${encodeURIComponent(r.id)}`;
const body = JSON.stringify({ status: !r.status });
await fetchJson(url, { method: "PATCH", headers: this.headersJson(), body });
this.log("update ok", r.id, `status=${!r.status}`);
await this.refresh();
} catch (e) {
this.log("update err", e?.status || "", e?.data || e?.message || "");
} finally {
this.busy.updateId = "";
}
},
async deleteReport(r) {
this.busy.deleteId = r.id;
try {
const url = `${this.pbBase()}/api/collections/${encodeURIComponent(this.cfg.collection)}/records/${encodeURIComponent(r.id)}`;
await fetchJson(url, { method: "DELETE", headers: this.headersAuthOnly() });
this.log("delete ok", r.id);
await this.refresh();
} catch (e) {
this.log("delete err", e?.status || "", e?.data || e?.message || "");
} finally {
this.busy.deleteId = "";
}
},
fileUrl(r) {
const base = this.pbBase();
if (!r?.attachment) return "#";
return `${base}/api/files/${encodeURIComponent(r.collectionId)}/${encodeURIComponent(r.id)}/${encodeURIComponent(r.attachment)}`;
},
}));
});


