PocketBase × Alpine.js Hands-On ── 単一HTMLで作るファイル管理UI

PocketBase × Alpine.js Hands-On ── 単一HTMLで作るファイル管理U HowTo
PocketBase × Alpine.js Hands-On ── 単一HTMLで作るファイル管理U

PocketBase について調べると、
だいたい同じ説明に行き当たります。

「Go の単一バイナリで動く」
「軽量な Firebase 代替」
「ローカルで完結する BaaS」

どれも事実ですが、
それだけでは PocketBase の価値は見えてきません。

今回触りたいのは、
あまり語られていない 「File の扱いやすさ」です。

ファイルが
単なる置き場ではなく、
文脈を持ったデータとして自然に扱えること。

単一HTMLから API を叩き、
ファイル付きの簡単な管理UIを作りながら、
その感触を確かめていきます。

完成度の高いツールを作るのが目的ではありません。
「なぜ、これが成立するのか」を理解するための Hands-Onです。

PocketBase - Open Source backend in 1 file
Open Source backend in 1 file with realtime database, authentication, file storage and admin dashboard
  1. 今回作るもの(完成イメージ)
    1. なぜ「報告書管理」なのか
    2. ゴールは「完成」ではない
  2. なぜ「単一HTML」で作るのか
    1. 初心者がつまずくポイントを減らす
    2. PocketBase は「APIが最初から生えている」
    3. Alpine.js を使う理由(この時点での話)
    4. 単一HTMLでも「実用」になるか?
  3. PocketBase 側の準備(最小構成)
    1. 今回のゴールを先に決める
    2. コレクション構成
      1. 1. users(組み込み)
      2. 2. reports(自作)
    3. relation は「どこで張るか」
    4. access rule(ここが肝)
      1. list / view / create / update / delete
    5. title に required を付ける理由
    6. status は bool でいいのか?
    7. 正直ポイント①
  4. フロント側の構成(index.html と report.js)
    1. index.html の役割
    2. report.js の役割
    3. PocketBase URL をUIで切り替えられるようにする
    4. “更新(同期)”ボタンの位置づけ
    5. 正直ポイント②
  5. PocketBase にログインする(auth の最小構成)
    1. users コレクションを使う理由
    2. ログインの API
    3. token の扱い(重要)
    4. ログイン状態の復元(restore session)
    5. 表示切り替え(x-if を使う理由)
    6. 正直ポイント③
  6. reports 一覧を取得する(REST を直叩きする気持ちよさ)
    1. 一覧取得でやることは2つだけ
      1. 1) token を付けて叩く
      2. 2) “自分の分だけ”に絞る
    2. “Rules で絞っているのに filter も書くの?”
    3. レスポンスはこうなる(見慣れた形)
    4. relation は “展開しない” で成立する
    5. 更新(同期)ボタンが効く瞬間
    6. 正直ポイント④
  7. reports を作成する(FormData で file を送る)
    1. なぜ JSON ではなく FormData なのか
    2. 作成時に送るフィールド
    3. FormData の作り方(実際の感覚)
    4. ファイルが保存される場所
    5. 正直ポイント⑤
  8. まとめ ── この構成が「ちょうどいい」理由
    1. なぜ「単一HTML」で成立したのか
    2. Alpine.js を“主役にしなかった理由
    3. この構成が向いている人
    4. 正直ポイントまとめ
    5. 最後に
  9. 完成コードについて
      1. index.html
      2. report.js

今回作るもの(完成イメージ)

今回のHands-Onで作るのは、とても小さなファイル管理UIです。

いわゆる「業務システム」ではありません。
承認フローも、締切管理も、権限の枝分かれもありません。

やることは、これだけです。

  • PocketBase のユーザーでログインする
  • 自分の報告書一覧を見る
  • テキストとファイルを添えて、新しい報告書を作る
  • 添付ファイルをダウンロードする

対象ユーザーは 自分ひとり
まずは「1人で使って成立するか」を確かめます。

画面構成もシンプルです。

  • 上部にログイン状態の表示
  • 入力フォーム(タイトル/メモ/ファイル)
  • 下に一覧テーブル

CSSも最小限。
「見栄え」より「何をしているかが分かる」ことを優先しています。


なぜ「報告書管理」なのか

この題材を選んだ理由は単純です。

  • ファイルが主役になる
  • テキストとファイルを一緒に扱う
  • 業務でも日常でも想像しやすい

そして何より、
PocketBase の File 機能を自然に使えるからです。

単なる ToDo やメモでは、
File フィールドの価値が見えにくい。

今回はあえて、

「ファイルが“ただの置き場”ではなく、
文脈を持ったデータになる」

その感触を確かめることを目的にしています。


ゴールは「完成」ではない

ここで大事な前提をはっきりさせておきます。

このHands-Onのゴールは、

  • 立派なアプリを作ること
  • ベストプラクティスを網羅すること

ではありません。

「単一HTMLでも、ここまで実用になる」
それを確認することがゴールです。

途中で感じた違和感や引っかかりも、
そのまま書いていきます。

それらは失敗ではなく、
設計を決めるための情報だからです。


PocketBase “報告書管理” 最小UI/Hands-ON用の最小素材

なぜ「単一HTML」で作るのか

今回のHands-Onでは、ビルド環境を一切使いません

  • npm なし
  • bundler なし
  • framework CLI なし

index.htmlreport.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
A rugged, minimal framework for composing behavior directly in your markup.

この段階では、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 ]

relation は「どこで張るか」

これは迷いやすいですが、答えは単純です。

  • reports 側に張る
  • users には何も追加しない

reports.owner → users.id
これだけ。

PocketBase は relation を
「参照側にだけ持たせる」設計が基本です。

PocketBase の Relation はフィールド定義の初期指定で決まる。
Relation はフィールド定義の初期指定で決まる。

access rule(ここが肝)

今回、一番大事なのはここです。

list / view / create / update / delete

すべて、同じ考え方で統一します。

@request.auth.id != "" && owner = @request.auth.id

意味はそのまま。

  • ログインしていること
  • 自分のレコードであること

これだけで、

  • 他人のデータは見えない
  • 勝手に編集できない

という状態が完成します。

PocketBase「報告書管理」Hands-ONのための API Rules 定義 [ reposrts ]

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点で使います。

  1. fetch の Authorization ヘッダ
  2. localStorage に保存
  3. 画面の表示切り替え
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 null
  • x-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つ。

  1. Content-Type を自分で指定しない
  2. file はあれば append、なければ送らない
  3. 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つあります。

  1. PocketBase が API + 認証 + ファイル + 管理UI を全部持っている
  2. Alpine.js が「DOMに近い場所」で自然に書ける
  3. 今回は「状態管理」を欲張らなかった

特に大きいのは、

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)}`;
    },
  }));
});