クリックで頂点を追加・Enter/ダブルクリックで確定・Escでキャンセル・Backspaceで1点戻る
{ el.style.opacity='0'; }, 2400);
}
const LS_KEY = 'page-builder-autosave';
function currentProjectData(){
syncPageFromState();
syncDeviceFromState();
return {
version: 3,
canvasWidth: state.canvasWidth,
canvasHeight: state.canvasHeight,
canvasBg: state.canvasBg,
desktopWidth: state.desktopWidth,
mobileWidth: state.mobileWidth,
device: state.device,
site: state.site,
pages: state.pages,
currentPageId: state.currentPageId,
els: state.els, // kept so v2 readers still see the current page
};
}
async function saveTemp(){
const data = JSON.stringify(Object.assign(currentProjectData(), { savedAt: Date.now() }));
if(window.storage){
try{
const result = await window.storage.set('autosave', data, false);
flashSaveStatus(result ? '保存しました' : '保存に失敗しました', !result);
return;
} catch(err){ console.error(err); }
}
try{
localStorage.setItem(LS_KEY, data);
flashSaveStatus('保存しました(このブラウザ内)');
} catch(err){
console.error(err);
flashSaveStatus('保存に失敗しました', true);
}
}
async function loadTemp(){
if(window.storage){
try{
const result = await window.storage.get('autosave', false);
if(result && result.value){ applyLoadedProject(JSON.parse(result.value)); return true; }
} catch(err){ console.log('No artifact autosave', err); }
}
try{
const raw = localStorage.getItem(LS_KEY);
if(raw){ applyLoadedProject(JSON.parse(raw)); return true; }
} catch(err){ console.log('No local autosave', err); }
return false;
}
$('btnSaveTemp').addEventListener('click', saveTemp);
// ---------------- Cloud (Google login + server-side projects) ----------------
const cloud = { user: null, currentProjectId: null, currentProjectName: null };
function renderAccount(){
const area = $('accountArea');
if(!area) return;
if(cloud.user){
area.innerHTML = `👤 ${escapeHtml(cloud.user.email)}`;
$('btnLogout').addEventListener('click', cloudLogout);
} else {
area.innerHTML = ``;
$('btnLogin').addEventListener('click', ()=>{ window.location.href = '/api/auth/login'; });
}
}
async function refreshAuth(){
try{
const res = await fetch('/api/auth/me', { credentials:'same-origin' });
const j = await res.json();
cloud.user = j.user || null;
} catch(err){ cloud.user = null; }
renderAccount();
}
function ensureLogin(){
if(cloud.user) return true;
if(confirm('クラウド機能を使うにはGoogleログインが必要です。ログインしますか?')){
window.location.href = '/api/auth/login';
}
return false;
}
async function cloudLogout(){
try{ await fetch('/api/auth/logout', { method:'POST', credentials:'same-origin' }); }catch(err){}
cloud.user = null; cloud.currentProjectId = null; cloud.currentProjectName = null;
renderAccount();
flashSaveStatus('ログアウトしました');
}
async function cloudSave(){
if(!ensureLogin()) return;
const data = currentProjectData();
const btn = $('btnCloudSave');
const original = btn.textContent;
btn.disabled = true; btn.textContent = '保存中…';
try{
let res;
if(cloud.currentProjectId){
res = await fetch('/api/projects/' + cloud.currentProjectId, {
method:'PUT', credentials:'same-origin', headers:{ 'content-type':'application/json' },
body: JSON.stringify({ name: cloud.currentProjectName, data })
});
} else {
const name = prompt('プロジェクト名を入力してください', cloud.currentProjectName || 'マイプロジェクト');
if(name === null){ return; }
res = await fetch('/api/projects', {
method:'POST', credentials:'same-origin', headers:{ 'content-type':'application/json' },
body: JSON.stringify({ name: name || '無題のプロジェクト', data })
});
}
if(res.status === 401){ cloud.user = null; renderAccount(); ensureLogin(); return; }
if(!res.ok) throw new Error('HTTP ' + res.status);
const j = await res.json();
if(j.id) cloud.currentProjectId = j.id;
if(j.name) cloud.currentProjectName = j.name;
flashSaveStatus('クラウドに保存しました');
} catch(err){
console.error(err);
flashSaveStatus('クラウド保存に失敗しました', true);
} finally {
btn.disabled = false; btn.textContent = original;
}
}
function closeCloudModal(){ $('cloudModal').classList.remove('open'); }
async function openCloudList(){
if(!ensureLogin()) return;
const body = $('cloudModalBody');
body.innerHTML = '読み込み中…
';
$('cloudModal').classList.add('open');
try{
const res = await fetch('/api/projects', { credentials:'same-origin' });
if(res.status === 401){ closeCloudModal(); cloud.user = null; renderAccount(); ensureLogin(); return; }
if(!res.ok) throw new Error('HTTP ' + res.status);
const j = await res.json();
const projects = j.projects || [];
if(!projects.length){
body.innerHTML = '保存済みのプロジェクトはまだありません。
「☁ クラウド保存」で保存すると、ここに表示されます。
';
return;
}
body.innerHTML = projects.map(p=>`
${escapeHtml(p.name)}
更新: ${new Date(p.updated_at).toLocaleString('ja-JP')}
`).join('');
body.querySelectorAll('.proj-item').forEach(n=>{
n.addEventListener('click', (e)=>{
if(e.target.closest('[data-del]')) return;
loadCloudProject(n.dataset.id);
});
});
body.querySelectorAll('[data-del]').forEach(b=>{
b.addEventListener('click', async (e)=>{
e.stopPropagation();
if(!confirm('このプロジェクトを削除しますか?この操作は元に戻せません。')) return;
await deleteCloudProject(b.dataset.del);
openCloudList();
});
});
} catch(err){
console.error(err);
body.innerHTML = '一覧の取得に失敗しました。
';
}
}
async function loadCloudProject(id){
if(state.els.length && !confirm('現在のキャンバスの内容は上書きされます。よろしいですか?')) return;
try{
const res = await fetch('/api/projects/' + id, { credentials:'same-origin' });
if(res.status === 401){ closeCloudModal(); cloud.user = null; renderAccount(); ensureLogin(); return; }
if(!res.ok) throw new Error('HTTP ' + res.status);
const j = await res.json();
if(state.drawingMode) cancelPenTool();
applyLoadedProject(j.data || {});
cloud.currentProjectId = j.id;
cloud.currentProjectName = j.name;
closeCloudModal();
flashSaveStatus('クラウドから読み込みました');
} catch(err){
console.error(err);
flashSaveStatus('読み込みに失敗しました', true);
}
}
async function deleteCloudProject(id){
try{
const res = await fetch('/api/projects/' + id, { method:'DELETE', credentials:'same-origin' });
if(!res.ok && res.status !== 404) throw new Error('HTTP ' + res.status);
if(cloud.currentProjectId === id){ cloud.currentProjectId = null; cloud.currentProjectName = null; }
} catch(err){ console.error(err); flashSaveStatus('削除に失敗しました', true); }
}
$('btnCloudSave').addEventListener('click', cloudSave);
$('btnCloudOpen').addEventListener('click', openCloudList);
$('cloudModalClose').addEventListener('click', closeCloudModal);
$('cloudModal').addEventListener('click', (e)=>{ if(e.target === $('cloudModal')) closeCloudModal(); });
// ---------------- Publish ----------------
const publishState = { slug: null, url: null };
function closePublishModal(){ $('publishModal').classList.remove('open'); }
function renderPublishModal(view){
const body = $('publishModalBody');
if(view.loading){
body.innerHTML = `${escapeHtml(view.loading)}
`;
return;
}
if(view.error){
body.innerHTML = `${escapeHtml(view.error)}
`;
$('pubRetry').addEventListener('click', openPublishModal);
return;
}
if(view.published){
body.innerHTML = `
公開しました 🎉
更新したいときは、もう一度この公開ボタンを押すと同じURLのまま差し替わります。
`;
$('pubCopy').addEventListener('click', async ()=>{
try{ await navigator.clipboard.writeText(view.url); flashSaveStatus('URLをコピーしました'); }
catch(err){ $('pubUrl').select(); }
});
$('pubOpen').addEventListener('click', ()=>window.open(view.url, '_blank', 'noopener'));
$('pubUnpublish').addEventListener('click', unpublishSite);
return;
}
const pageCount = state.pages.length;
body.innerHTML = `
${publishState.slug ? `
` : ''}
${pageCount}ページを公開します。${publishState.slug ? '既存のURLを上書きします。' : '公開後にURLが発行されます。'}
公開ページは誰でも閲覧できます。編集用のデータは含まれません。
`;
$('pubGo').addEventListener('click', ()=>doPublish($('pubTitle').value));
}
async function openPublishModal(){
if(!ensureLogin()) return;
$('publishModal').classList.add('open');
renderPublishModal({ loading: '公開状況を確認中…' });
try{
const res = await fetch('/api/publish', { credentials:'same-origin' });
if(res.status === 401){ closePublishModal(); cloud.user = null; renderAccount(); ensureLogin(); return; }
const j = await res.json();
// Match this project to an existing site so republishing keeps the URL.
const mine = (j.sites || []).find(sitesMatch);
if(mine){
publishState.slug = mine.slug;
publishState.url = new URL('/s/' + mine.slug + '/', location.origin).href;
}
renderPublishModal({});
}catch(err){
console.error(err);
renderPublishModal({ error: '公開状況の取得に失敗しました。' });
}
}
function sitesMatch(site){
if(cloud.currentProjectId && site.project_id) return site.project_id === cloud.currentProjectId;
return publishState.slug ? site.slug === publishState.slug : false;
}
async function doPublish(title){
renderPublishModal({ loading: 'ページを生成して公開中…' });
try{
const { files } = buildSiteFiles({ inlineImages: true });
const res = await fetch('/api/publish', {
method:'POST',
credentials:'same-origin',
headers:{ 'content-type':'application/json' },
body: JSON.stringify({
title: title || 'マイサイト',
slug: publishState.slug || undefined,
projectId: cloud.currentProjectId || undefined,
files,
}),
});
const j = await res.json().catch(()=>({}));
if(res.status === 401){ closePublishModal(); cloud.user = null; renderAccount(); ensureLogin(); return; }
if(!res.ok){
renderPublishModal({ error: j.message || ('公開に失敗しました (HTTP ' + res.status + ')') });
return;
}
publishState.slug = j.slug;
publishState.url = j.url;
renderPublishModal({ published:true, url:j.url });
flashSaveStatus('公開しました');
}catch(err){
console.error(err);
renderPublishModal({ error: '公開に失敗しました。通信環境を確認してください。' });
}
}
async function unpublishSite(){
if(!publishState.slug) return;
if(!confirm('公開を停止すると、URLにアクセスできなくなります。よろしいですか?')) return;
renderPublishModal({ loading: '公開を停止中…' });
try{
const res = await fetch('/api/publish/' + publishState.slug, { method:'DELETE', credentials:'same-origin' });
if(!res.ok && res.status !== 404) throw new Error('HTTP ' + res.status);
publishState.slug = null;
publishState.url = null;
renderPublishModal({});
flashSaveStatus('公開を停止しました');
}catch(err){
console.error(err);
renderPublishModal({ error: '公開の停止に失敗しました。' });
}
}
$('btnPublish').addEventListener('click', openPublishModal);
$('publishModalClose').addEventListener('click', closePublishModal);
$('publishModal').addEventListener('click', (e)=>{ if(e.target === $('publishModal')) closePublishModal(); });
// ---------------- Init ----------------
(async function init(){
const home = newPage('ホーム', { slug:'index' });
state.pages = [home];
state.currentPageId = home.id;
loadPageIntoState(home);
state.canvasWidth = state.desktopWidth;
syncCanvasBox();
renderAccount();
refreshAuth();
let restored = false;
try{ restored = await loadTemp(); }catch(err){ console.log(err); }
renderPages();
zoomToFit();
historyReset();
$('btnSnap').classList.toggle('toggled', state.snap);
})();