parent
492da3f2d9
commit
1e9e19b4b6
|
|
@ -204,7 +204,7 @@ posimai-store(LP)
|
|||
| posimai-store | Stripe / VPS | アプリ販売 LP |
|
||||
| posimai-log | — | 開発ログビューワー(scribe 連携) |
|
||||
| posimai-boki | — | 簿記2級 学習 PWA(localStorage) |
|
||||
| posimai-sc | — | 支援士試験 学習 PWA(localStorage・非公式補助) |
|
||||
| posimai-sc | VPS API(app-state) | 支援士試験 学習 PWA(初学者向け・用語インデックス・試験モード・端末間再開同期) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -135,6 +135,14 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
|
|||
.home-hint strong{color:var(--text2);font-weight:600}
|
||||
.home-hint kbd{font-family:'JetBrains Mono',monospace;font-size:10px;padding:2px 5px;border:1px solid var(--border);border-radius:4px;background:var(--surface)}
|
||||
.home-tools{display:flex;justify-content:center;flex-wrap:wrap;gap:10px;margin-bottom:16px}
|
||||
.sync-card{max-width:540px;margin:0 auto 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px}
|
||||
.sync-title{font-size:11px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;display:flex;align-items:center;gap:6px;margin-bottom:8px}
|
||||
.sync-text{font-size:12px;color:var(--text2);line-height:1.6}
|
||||
.sync-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}
|
||||
.sync-status{font-size:11px;color:var(--text3)}
|
||||
.sync-error{font-size:11px;color:var(--err);margin-top:4px}
|
||||
.sync-input{flex:1;min-width:220px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:7px 10px;font-size:12px;color:var(--text);outline:none;font-family:'JetBrains Mono',monospace}
|
||||
.sync-input:focus{border-color:var(--accent-border)}
|
||||
.glossary-intro{font-size:12px;color:var(--text2);line-height:1.65;margin-bottom:12px}
|
||||
.glossary-search-wrap{position:relative;margin-bottom:8px}
|
||||
.glossary-search-icon{position:absolute;left:8px;top:50%;transform:translateY(-50%);color:var(--text3);pointer-events:none}
|
||||
|
|
@ -478,6 +486,23 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sync-card">
|
||||
<div class="sync-title">
|
||||
<i data-lucide="cloud" style="width:12px;height:12px"></i>
|
||||
端末間の再開同期
|
||||
</div>
|
||||
<div class="sync-text">PC とスマホで同じ学習状態を再開するため、クラウドに進捗を保存します。</div>
|
||||
<div class="sync-row" x-show="syncEnabled">
|
||||
<span class="sync-status" x-text="syncStatus"></span>
|
||||
<button class="btn-sm" type="button" :disabled="syncBusy" @click="syncNow()">今すぐ同期</button>
|
||||
</div>
|
||||
<div class="sync-row" x-show="!syncEnabled">
|
||||
<input class="sync-input" type="password" placeholder="posimai_api_key または JWT" x-model="syncToken" aria-label="同期キー入力">
|
||||
<button class="btn-sm btn-accent" type="button" @click="enableSyncWithInput()">同期を有効化</button>
|
||||
</div>
|
||||
<div class="sync-error" x-show="syncLastError" x-text="syncLastError"></div>
|
||||
</div>
|
||||
|
||||
<!-- Weak drill button -->
|
||||
<div style="margin-bottom:20px;display:flex;justify-content:center">
|
||||
<button class="btn-sm" :class="weakDrillCandidates.length ? 'btn-accent' : ''"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { DRILLS } from './data/drills.js';
|
||||
import { CATEGORIES } from './data/categories.js';
|
||||
|
||||
const API_BASE = 'https://api.soar-enrich.com/brain/api';
|
||||
const APP_ID = 'posimai-sc';
|
||||
const SYNC_KEY_LS = 'posimai_api_key';
|
||||
|
||||
function shuffleInPlace(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
|
|
@ -127,6 +131,14 @@ document.addEventListener('alpine:init', () => {
|
|||
examQuestions:[],
|
||||
examIdx:0,
|
||||
examQuizState:{},
|
||||
syncToken:'',
|
||||
syncEnabled:false,
|
||||
syncBusy:false,
|
||||
syncStatus:'未同期',
|
||||
syncLastError:'',
|
||||
syncStateAt:0,
|
||||
syncRemoteAt:0,
|
||||
syncTimer:null,
|
||||
|
||||
init(){
|
||||
this.categories = CATEGORIES.map(cat=>({
|
||||
|
|
@ -140,6 +152,11 @@ document.addEventListener('alpine:init', () => {
|
|||
catch{ this.scores = {}; }
|
||||
try{ this.wrongUnits = JSON.parse(localStorage.getItem('posimai-sc-wrong')||'{}'); }
|
||||
catch{ this.wrongUnits = {}; }
|
||||
this.syncStateAt = parseInt(localStorage.getItem('posimai-sc-state-at') || '0', 10) || 0;
|
||||
const jwtToken = (localStorage.getItem('posimai_token') || '').trim();
|
||||
const apiKey = (localStorage.getItem(SYNC_KEY_LS) || '').trim();
|
||||
this.syncToken = jwtToken || apiKey;
|
||||
this.syncEnabled = !!this.syncToken;
|
||||
const t = localStorage.getItem('posimai-sc-theme')||'system';
|
||||
this.isDark = t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);
|
||||
const uid=(new URLSearchParams(location.search).get('unit')||'').trim().toLowerCase();
|
||||
|
|
@ -153,6 +170,11 @@ document.addEventListener('alpine:init', () => {
|
|||
this.$watch('search', ()=>{
|
||||
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
|
||||
});
|
||||
if(this.syncEnabled){
|
||||
this.pullCloudState().catch(()=>{});
|
||||
} else {
|
||||
this.syncStatus = '同期キー未設定';
|
||||
}
|
||||
if('serviceWorker' in navigator){
|
||||
navigator.serviceWorker.register('/sw.js').catch(()=>{});
|
||||
}
|
||||
|
|
@ -180,6 +202,127 @@ document.addEventListener('alpine:init', () => {
|
|||
history.replaceState(null,'',path);
|
||||
}catch(e){}
|
||||
},
|
||||
nowTs(){
|
||||
return Date.now();
|
||||
},
|
||||
get localStateBlob(){
|
||||
return {
|
||||
progress: this.progress || {},
|
||||
scores: this.scores || {},
|
||||
wrongUnits: this.wrongUnits || {},
|
||||
currentUnitId: this.currentUnit?.id || '',
|
||||
stateAt: this.syncStateAt || this.nowTs()
|
||||
};
|
||||
},
|
||||
applyStateBlob(blob){
|
||||
const nextProgress = (blob && typeof blob.progress === 'object') ? blob.progress : {};
|
||||
const nextScores = (blob && typeof blob.scores === 'object') ? blob.scores : {};
|
||||
const nextWrong = (blob && typeof blob.wrongUnits === 'object') ? blob.wrongUnits : {};
|
||||
this.progress = { ...nextProgress };
|
||||
this.scores = { ...nextScores };
|
||||
this.wrongUnits = { ...nextWrong };
|
||||
try{ localStorage.setItem('posimai-sc-progress', JSON.stringify(this.progress)); }catch(e){}
|
||||
try{ localStorage.setItem('posimai-sc-scores', JSON.stringify(this.scores)); }catch(e){}
|
||||
try{ localStorage.setItem('posimai-sc-wrong', JSON.stringify(this.wrongUnits)); }catch(e){}
|
||||
if(blob && blob.currentUnitId){
|
||||
const u = this.allUnits().find(x => x.id === blob.currentUnitId);
|
||||
if(u) this.currentUnit = u;
|
||||
}
|
||||
this.syncStateAt = parseInt(String(blob?.stateAt || this.nowTs()), 10) || this.nowTs();
|
||||
try{ localStorage.setItem('posimai-sc-state-at', String(this.syncStateAt)); }catch(e){}
|
||||
this.syncUnitToUrl();
|
||||
},
|
||||
markStateChanged(){
|
||||
this.syncStateAt = this.nowTs();
|
||||
try{ localStorage.setItem('posimai-sc-state-at', String(this.syncStateAt)); }catch(e){}
|
||||
if(this.syncEnabled) this.scheduleCloudPush();
|
||||
},
|
||||
get syncHeader(){
|
||||
const token = (localStorage.getItem('posimai_token') || '').trim() || (this.syncToken || '').trim();
|
||||
return token ? { Authorization: 'Bearer ' + token } : null;
|
||||
},
|
||||
async pullCloudState(){
|
||||
const hdr = this.syncHeader;
|
||||
if(!hdr){
|
||||
this.syncEnabled = false;
|
||||
this.syncStatus = '同期キー未設定';
|
||||
return;
|
||||
}
|
||||
this.syncBusy = true;
|
||||
this.syncStatus = '同期を確認中...';
|
||||
this.syncLastError = '';
|
||||
try{
|
||||
const res = await fetch(`${API_BASE}/app-state/${APP_ID}`, { headers: hdr });
|
||||
if(res.status===404){
|
||||
this.syncEnabled = true;
|
||||
this.syncStatus = 'クラウド初期化前(この端末の状態を保存できます)';
|
||||
return;
|
||||
}
|
||||
if(!res.ok) throw new Error('HTTP '+res.status);
|
||||
const data = await res.json();
|
||||
const remoteState = data?.state || {};
|
||||
const remoteAt = parseInt(String(remoteState?.stateAt || 0), 10) || 0;
|
||||
this.syncRemoteAt = remoteAt;
|
||||
if(remoteAt > (this.syncStateAt || 0)){
|
||||
this.applyStateBlob(remoteState);
|
||||
this.syncStatus = 'クラウドから最新状態を復元';
|
||||
} else if(remoteAt > 0){
|
||||
this.syncStatus = 'この端末の状態が最新';
|
||||
} else {
|
||||
this.syncStatus = 'クラウド初期化前(この端末の状態を保存できます)';
|
||||
}
|
||||
}catch(e){
|
||||
this.syncLastError = String(e?.message || e || '同期失敗');
|
||||
this.syncStatus = '同期エラー';
|
||||
}finally{
|
||||
this.syncBusy = false;
|
||||
}
|
||||
},
|
||||
async pushCloudState(){
|
||||
const hdr = this.syncHeader;
|
||||
if(!hdr || !this.syncEnabled) return;
|
||||
this.syncBusy = true;
|
||||
this.syncStatus = 'クラウドへ保存中...';
|
||||
this.syncLastError = '';
|
||||
try{
|
||||
const payload = this.localStateBlob;
|
||||
const res = await fetch(`${API_BASE}/app-state/${APP_ID}`, {
|
||||
method:'PUT',
|
||||
headers:{ 'Content-Type':'application/json', ...hdr },
|
||||
body: JSON.stringify({ state: payload })
|
||||
});
|
||||
if(!res.ok) throw new Error('HTTP '+res.status);
|
||||
this.syncRemoteAt = payload.stateAt;
|
||||
this.syncStatus = 'クラウド同期済み';
|
||||
}catch(e){
|
||||
this.syncLastError = String(e?.message || e || '同期失敗');
|
||||
this.syncStatus = '同期エラー';
|
||||
}finally{
|
||||
this.syncBusy = false;
|
||||
}
|
||||
},
|
||||
scheduleCloudPush(){
|
||||
if(this.syncTimer) clearTimeout(this.syncTimer);
|
||||
this.syncTimer = setTimeout(()=>{ this.pushCloudState().catch(()=>{}); }, 800);
|
||||
},
|
||||
enableSyncWithInput(){
|
||||
const token = (this.syncToken || '').trim();
|
||||
if(!token){
|
||||
this.syncStatus = '同期キー未設定';
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(SYNC_KEY_LS, token);
|
||||
this.syncEnabled = true;
|
||||
this.pullCloudState().catch(()=>{});
|
||||
},
|
||||
async syncNow(){
|
||||
if(!this.syncEnabled){
|
||||
this.syncStatus = '同期キー未設定';
|
||||
return;
|
||||
}
|
||||
await this.pushCloudState();
|
||||
await this.pullCloudState();
|
||||
},
|
||||
|
||||
goHome(){
|
||||
this.currentUnit=null;
|
||||
|
|
@ -194,6 +337,7 @@ document.addEventListener('alpine:init', () => {
|
|||
this.examQuestions=[];
|
||||
this.examIdx=0;
|
||||
this.examQuizState={};
|
||||
this.markStateChanged();
|
||||
this.syncUnitToUrl();
|
||||
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
|
||||
},
|
||||
|
|
@ -251,6 +395,7 @@ document.addEventListener('alpine:init', () => {
|
|||
if(!this.currentUnit) return;
|
||||
this.progress[this.currentUnit.id]=true;
|
||||
localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress));
|
||||
this.markStateChanged();
|
||||
},
|
||||
bestScore(id){
|
||||
const s=this.scores[id];
|
||||
|
|
@ -269,6 +414,7 @@ document.addEventListener('alpine:init', () => {
|
|||
if(!prev||correct>prev.best){
|
||||
this.scores[id]={best:correct,total};
|
||||
localStorage.setItem('posimai-sc-scores',JSON.stringify(this.scores));
|
||||
this.markStateChanged();
|
||||
}
|
||||
},
|
||||
get freqBadgeCls(){
|
||||
|
|
@ -295,6 +441,7 @@ document.addEventListener('alpine:init', () => {
|
|||
this.quizState={};
|
||||
this.conceptExpanded=true;
|
||||
this.stepMode=false;
|
||||
this.markStateChanged();
|
||||
this.syncUnitToUrl();
|
||||
this.$nextTick(()=>{
|
||||
if(window.lucide) lucide.createIcons();
|
||||
|
|
@ -317,12 +464,14 @@ document.addEventListener('alpine:init', () => {
|
|||
if(!this.progress[uid]){
|
||||
this.progress[uid]=true;
|
||||
try{ localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress)); }catch(e){}
|
||||
this.markStateChanged();
|
||||
}
|
||||
} else {
|
||||
this.wrongUnits[uid]=(this.wrongUnits[uid]||0)+1;
|
||||
}
|
||||
try{ localStorage.setItem('posimai-sc-wrong',JSON.stringify(this.wrongUnits)); }catch(e){}
|
||||
this.wrongUnits={...this.wrongUnits};
|
||||
this.markStateChanged();
|
||||
},
|
||||
get weakUnits(){
|
||||
return this.allUnits().filter(u=>this.wrongUnits[u.id]>0)
|
||||
|
|
@ -637,6 +786,7 @@ document.addEventListener('alpine:init', () => {
|
|||
delete this.wrongUnits[unit.id];
|
||||
localStorage.setItem('posimai-sc-wrong',JSON.stringify(this.wrongUnits));
|
||||
this.wrongUnits={...this.wrongUnits};
|
||||
this.markStateChanged();
|
||||
}
|
||||
if(this.weakDrillUnitIdx<this.weakDrillUnits.length-1){
|
||||
this.weakDrillUnitIdx++;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// posimai-sc SW — same-origin の静的資産のみキャッシュ(CDN は対象外)
|
||||
const CACHE = 'posimai-sc-v4';
|
||||
const CACHE = 'posimai-sc-v5';
|
||||
const STATIC = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
|
|
|||
48
server.js
48
server.js
|
|
@ -842,6 +842,9 @@ async function togetherEnsureMemberForShare(pool, res, shareId, username, jwtUse
|
|||
}
|
||||
}
|
||||
|
||||
const APP_STATE_DIR = path.join(__dirname, 'app-state');
|
||||
if (!fs.existsSync(APP_STATE_DIR)) fs.mkdirSync(APP_STATE_DIR, { recursive: true });
|
||||
|
||||
// ── ルーター ──────────────────────────────
|
||||
function buildRouter() {
|
||||
const r = express.Router();
|
||||
|
|
@ -897,6 +900,51 @@ function buildRouter() {
|
|||
res.json({ ok: true, userId: req.userId });
|
||||
});
|
||||
|
||||
// ── App state sync (cross-device resume) ──────────────────────────
|
||||
r.get('/app-state/:appId', authMiddleware, (req, res) => {
|
||||
const appId = String(req.params.appId || '').trim();
|
||||
if (!/^[a-z0-9-]{2,40}$/.test(appId)) {
|
||||
return res.status(400).json({ error: 'invalid_app_id' });
|
||||
}
|
||||
const userDir = path.join(APP_STATE_DIR, req.userId);
|
||||
const file = path.join(userDir, `${appId}.json`);
|
||||
if (!fs.existsSync(file)) return res.status(404).json({ error: 'not_found' });
|
||||
try {
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return res.json({ ok: true, appId, userId: req.userId, state: parsed.state || {} });
|
||||
} catch (e) {
|
||||
console.error('[AppState] read error:', e.message);
|
||||
return res.status(500).json({ error: 'read_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
r.put('/app-state/:appId', authMiddleware, (req, res) => {
|
||||
const appId = String(req.params.appId || '').trim();
|
||||
if (!/^[a-z0-9-]{2,40}$/.test(appId)) {
|
||||
return res.status(400).json({ error: 'invalid_app_id' });
|
||||
}
|
||||
const state = req.body?.state;
|
||||
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
||||
return res.status(400).json({ error: 'invalid_state' });
|
||||
}
|
||||
// 安全上限: 100KB
|
||||
const payload = JSON.stringify({ appId, userId: req.userId, state, updatedAt: new Date().toISOString() });
|
||||
if (Buffer.byteLength(payload, 'utf8') > 100 * 1024) {
|
||||
return res.status(413).json({ error: 'state_too_large' });
|
||||
}
|
||||
const userDir = path.join(APP_STATE_DIR, req.userId);
|
||||
const file = path.join(userDir, `${appId}.json`);
|
||||
try {
|
||||
if (!fs.existsSync(userDir)) fs.mkdirSync(userDir, { recursive: true });
|
||||
fs.writeFileSync(file, payload, 'utf8');
|
||||
return res.json({ ok: true, appId, updatedAt: new Date().toISOString() });
|
||||
} catch (e) {
|
||||
console.error('[AppState] write error:', e.message);
|
||||
return res.status(500).json({ error: 'write_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Auth: Magic Link ─────────────────────────────────────────────
|
||||
|
||||
// POST /api/auth/magic-link/send
|
||||
|
|
|
|||
Loading…
Reference in New Issue