Scan to open on your phone
Point a camera at the QR code - it opens this exact paste, no app needed.
https://codepastes.com/zzgstgvcymqws7n2無題Plain Text
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>記録</title>
<style>
:root{
--main:#4f86c6;
--light:#eaf3ff;
--bg:#f4f6f8;
--text:#333;
--panelText:#333;
--inputBg:rgba(255,255,255,.96);
--inputText:#333;
--border:#ccc;
--titleFont:"Yu Gothic","Meiryo",sans-serif;
--dateFont:"Yu Gothic","Meiryo",sans-serif;
--bodyFont:"Yu Gothic","Meiryo",sans-serif;
--commentFont:"Yu Gothic","Meiryo",sans-serif;
--listFont:"Yu Gothic","Meiryo",sans-serif;
--settingFont:"Yu Gothic","Meiryo",sans-serif;
--titleSize:32px;
--dateSize:18px;
--bodySize:18px;
--commentSize:16px;
--listSize:18px;
--settingSize:16px;
--photoColumns:3;
--panelBg:rgba(255,255,255,1);
--photoBg:rgba(255,255,255,1);
--darkBorder:#444;
}
*{box-sizing:border-box}
body{
margin:0;
color:var(--text);
background:var(--bg);
background-size:cover;
background-position:center;
background-attachment:fixed;
font-family:var(--bodyFont);
transition:.2s
}
header{
height:64px;
display:flex;
align-items:center;
padding:0 22px;
background:var(--main);
color:white
}
header h1{
margin:0;
font-family:var(--titleFont);
font-size:var(--appTitleSize);
letter-spacing:.04em
}
.layout{
min-height:calc(100vh - 64px);
display:flex
}
.sidebar{
width:205px;
flex-shrink:0;
background:var(--panelBg);
border-right:1px solid #ddd;
padding:10px;
display:flex;
flex-direction:column
}
.side-btn{
width:100%;
border:0;
border-radius:8px;
padding:6px 10px;
margin-bottom:4px;
min-height:calc(var(--titleSize) * 1.8);
box-sizing:border-box;
background:transparent;
text-align:left;
cursor:pointer;
font-family:var(--titleFont);
font-size:var(--titleSize);
line-height:1.4;
color:var(--panelText)
}
.side-btn:hover{
background:var(--light)
}
.side-btn.active{
background:var(--main);
color:white;
font-weight:bold
}
.sidebar-space{
flex:1
}
main{
flex:1;
min-width:0;
padding:24px
}
.panel,
.detail,
.settings-section{
background:var(--panelBg);
border-radius:12px;
padding:24px;
box-shadow:0 2px 9px #0000000c;
color:var(--panelText)
}
input,
textarea{
width:100%;
border:1px solid var(--border);
border-radius:8px;
padding:10px;
background:var(--inputBg);
color:var(--inputText)
}
input{
margin-bottom:12px;
font-family:var(--bodyFont);
font-size:var(--bodySize)
}
/* 編集画面:指定項目は「編集」の文字設定を使う */
#tripDate,
#tripStation,
#tripTravelMinutes,
#tripRating,
#tripAccess,
#tripPrefecture,
#tripPurpose1,
#tripPurpose2,
#tripPurpose3,
[id^="tripStartMonth"],
[id^="tripStartPeriod"],
[id^="tripEndMonth"],
[id^="tripEndPeriod"]{
font-family:var(--editFont);
font-size:var(--editSize);
}
textarea{
min-height:calc(var(--bodySize) * 13);
resize:vertical;
font-family:var(--bodyFont);
font-size:var(--bodySize);
line-height:1.8
}
.editor-heading{
margin:0 0 18px;
padding:12px 0;
border-bottom:2px solid var(--main)
}
.editor-heading-title{
display:block;
font-family:var(--titleFont);
font-size:24px;
font-weight:bold;
line-height:1.4;
color:var(--main)
}
.title-input{
font-family:var(--editFont);
font-size:var(--editSize);
font-weight:bold;
border:1px solid var(--border);
border-radius:8px;
padding:11px 13px;
background:var(--inputBg);
margin-bottom:12px
}
.title-input:focus{
outline:none;
border-color:var(--main);
box-shadow:0 0 0 3px rgba(79,134,198,.12)
}
.date-row{
display:flex;
align-items:center;
margin-bottom:12px
}
.date-input-wrap{
display:flex;
align-items:center;
width:max-content;
border:1px solid var(--border);
border-radius:8px;
padding:0 8px 0 10px;
background:var(--inputBg);
color:var(--inputText)
}
.date-input-wrap input{
width:auto;
min-width:0;
flex:0 0 auto;
margin:0;
padding:10px 4px 10px 0;
border:0;
border-radius:0;
background:transparent;
font-family:var(--editFont);
font-size:var(--editSize);
font-weight:bold;
color:var(--inputText)
}
.date-input-wrap input:focus{
outline:none
}
.weekday-display{
white-space:nowrap;
flex-shrink:0;
font-family:var(--editFont);
font-size:var(--editSize);
font-weight:bold;
margin-left:4px
}
/* 写真欄 */
.photo-count-editor{
margin:15px 0;
padding:12px;
border:1px solid #ddd;
border-radius:9px;
background:rgba(248,250,252,.8)
}
.photo-count-editor-title{
margin:0 0 9px;
font-family:var(--editFont);
font-size:var(--editSize);
font-weight:bold;
color:var(--main)
}
.photo-count-editor-buttons{
display:flex;
gap:5px;
flex-wrap:wrap
}
.photo-count-editor-button{
border:1px solid #ccc;
border-radius:6px;
background:var(--panelBg);
color:var(--panelText);
padding:8px 12px;
cursor:pointer;
font-family:var(--editFont);
font-size:var(--editSize)
}
.photo-count-editor-button.selected{
background:var(--main);
color:white
}
.action{
border:0;
border-radius:7px;
padding:10px 16px;
margin:5px 4px 5px 0;
cursor:pointer;
font-family:var(--settingFont);
font-size:var(--settingSize)
}
.edit-action{
font-family:var(--editFont);
font-size:var(--editSize)
}
.action:disabled{
opacity:.55;
cursor:not-allowed
}
.save{
background:var(--main);
color:white
}
.cancel{
background:#ddd;
color:#333
}
.delete{
background:#c85656;
color:white
}
.year-buttons{
display:flex;
gap:7px;
flex-wrap:wrap;
margin-bottom:15px
}
.year-buttons button{
border:1px solid #ccc;
border-radius:7px;
background:var(--panelBg);
color:var(--panelText);
padding:8px 15px;
cursor:pointer;
font-family:var(--dateFont);
font-size:var(--dateSize)
}
.year-buttons button.active{
background:var(--main);
color:white
}
.trip{
display:flex;
align-items:center;
padding:15px 18px;
border-bottom:1px solid #eee;
cursor:pointer
}
.trip:hover{
background:var(--light)
}
.trip-date{
width:250px;
flex-shrink:0;
color:#777;
font-family:var(--dateFont);
font-size:var(--dateSize)
}
.trip-title{
font-family:var(--dateFont);
font-size:var(--dateSize);
font-weight:bold
}
.detail-title{
border-left:6px solid var(--main);
border-radius:8px;
background:var(--light);
padding:15px 18px;
margin-bottom:10px;
font-family:var(--titleFont);
font-size:var(--titleSize);
font-weight:bold
}
.detail-date{
display:inline-block;
padding:7px 12px;
margin-bottom:18px;
border-radius:7px;
background:var(--main);
color:white;
font-family:var(--dateFont);
font-size:var(--dateSize);
font-weight:bold
}
.detail-text{
padding:18px;
margin-bottom:20px;
border:1px solid #e4e4e4;
border-radius:9px;
white-space:pre-wrap;
overflow-wrap:anywhere;
font-family:var(--bodyFont);
font-size:var(--bodySize);
line-height:1.9
}
.photo-grid{
display:grid;
grid-template-columns:repeat(var(--photoColumns),minmax(0,1fr));
gap:12px;
width:100%;
border:2px solid #b8b8b8;
border-radius:10px;
padding:10px
}
.photo-item{
min-width:0;
padding:7px;
border:2px solid #aaa;
border-radius:9px;
background:var(--photoBg);
cursor:grab;
user-select:none
}
.photo-item.dragging,
.edit-photo.dragging{
opacity:.35
}
.photo-item.drag-over,
.edit-photo.drag-over{
outline:3px solid var(--main)
}
.photo-frame{
width:100%;
overflow:hidden;
border:2px solid #999;
border-radius:7px;
background:#eee
}
.photo-frame img{
width:100%;
height:auto;
display:block;
object-fit:contain;
cursor:pointer
}
.photo-comment-box{
margin-top:6px;
padding:6px 8px;
border:1px solid #aaa;
border-radius:6px;
background:rgba(255,255,255,.72)
}
.photo-comment{
font-family:var(--commentFont);
font-size:var(--commentSize);
line-height:1.5;
white-space:pre-wrap;
overflow-wrap:anywhere
}
.edit-photo-grid{
display:grid;
grid-template-columns:repeat(var(--photoColumns),minmax(0,1fr));
gap:12px;
margin:15px 0
}
.edit-photo{
min-width:0;
padding:7px;
border:2px solid #aaa;
border-radius:9px;
background:var(--photoBg);
cursor:grab
}
.edit-photo-frame{
overflow:hidden;
border:2px solid #999;
border-radius:7px;
background:#eee
}
.edit-photo-frame img{
width:100%;
height:auto;
display:block
}
.edit-comment-box{
margin-top:6px;
padding:5px;
border:1px solid #aaa;
border-radius:6px
}
.edit-photo textarea{
min-height:70px;
margin:0;
font-family:var(--commentFont);
font-size:var(--commentSize)
}
.drop-zone{
border:2px dashed #aab9b2;
border-radius:10px;
padding:20px;
text-align:center;
color:var(--main);
background:rgba(250,252,249,.8);
cursor:pointer;
line-height:1.7;
font-family:var(--settingFont)
}
.drop-zone.dragover{
background:var(--light)
}
.save-status,
.draft-status{
margin:10px 0;
padding:10px;
border-radius:7px;
display:none;
font-family:var(--settingFont)
}
.save-status{
background:#fff8d9;
color:#775f00
}
.draft-status{
background:#eef7ee;
color:#397039
}
.settings-section{
padding:18px;
margin-bottom:15px;
border:1px solid #ddd;
border-radius:10px;
background:var(--panelBg);
font-family:var(--settingFont)
}
.settings-section input{
font-family:var(--settingFont);
font-size:var(--settingSize)
}
.settings-section h2{
margin:0 0 14px;
color:var(--main);
font-family:var(--settingFont)
}
.color-list{
display:flex;
flex-wrap:wrap;
gap:7px
}
.color-choice{
width:44px;
height:44px;
border:3px solid transparent;
border-radius:50%;
cursor:grab;
flex:0 0 44px
}
.color-choice.selected{
border-color:#333;
box-shadow:0 0 0 2px white inset
}
.color-choice.white-color{
border-color:#333
}
.color-choice.white-color.selected{
border-color:#000;
box-shadow:0 0 0 2px white inset,0 0 0 3px #333
}
.size-setting{
padding:10px 0;
border-bottom:1px solid #eee
}
.size-setting-row{
display:flex;
align-items:center;
gap:10px;
flex-wrap:wrap
}
.size-setting-name{
width:80px;
font-weight:bold
}
.size-buttons{
display:flex;
gap:4px
}
.size-button{
min-width:34px;
min-height:32px;
padding:4px 8px;
border:1px solid #ccc;
border-radius:6px;
background:var(--panelBg);
color:var(--panelText);
cursor:pointer;
font-family:var(--settingFont);
font-size:var(--settingSize);
line-height:1.2
}
.size-button.selected{
background:var(--main);
color:white
}
.font-select{
display:flex;
align-items:center;
gap:5px
}
.font-choice{
min-width:105px;
height:34px;
padding:4px 8px;
border:1px solid #ccc;
border-radius:6px;
background:var(--panelBg);
color:var(--panelText);
cursor:pointer;
text-align:left;
font-size:var(--settingSize)
}
.font-choice.selected{
border:2px solid var(--main);
background:var(--light)
}
.font-choice-name{
margin-right:7px;
color:#555
}
.font-choice-sample{
font-size:20px;
font-weight:normal
}
.transparency-row{
display:flex;
gap:5px;
flex-wrap:wrap
}
.transparency-button{
border:1px solid #ccc;
border-radius:6px;
background:var(--panelBg);
color:var(--panelText);
padding:8px 12px;
cursor:pointer
}
.transparency-button.selected{
background:var(--main);
color:white
}
.dark-mode-row{
display:flex;
gap:5px;
flex-wrap:wrap
}
.dark-mode-button{
border:1px solid #ccc;
border-radius:6px;
background:var(--panelBg);
color:var(--panelText);
padding:8px 18px;
cursor:pointer;
font-family:var(--settingFont)
}
.dark-mode-button.selected{
background:var(--main);
color:white
}
.background-preview{
width:100%;
max-height:280px;
object-fit:cover;
border-radius:9px;
margin-top:10px
}
.photo-window{
position:fixed;
inset:0;
z-index:9999;
background:#000e;
display:flex;
justify-content:center;
align-items:center
}
/* =========================
起動・フォルダ選択画面
ヘッダー/サイドバー一時非表示
========================= */
body.permission-mode header,
body.permission-mode .sidebar{
display:none;
}
body.permission-mode .layout{
min-height:100vh;
}
body.permission-mode main{
width:100%;
min-height:100vh;
padding:0;
}
/* =========================
メモウィンドウ
========================= */
.memo-editor{
margin:15px 0;
}
.memo-editor-row{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:8px;
margin-bottom:8px;
}
.memo-editor-button{
min-width:0;
padding:9px 8px;
border:1px solid var(--border);
border-radius:7px;
background:var(--panelBg);
color:var(--panelText);
cursor:pointer;
font-family:var(--commentFont);
font-size:var(--commentSize);
text-align:left;
overflow:hidden;
white-space:nowrap;
text-overflow:ellipsis;
}
.memo-editor-button:hover{
background:var(--light);
}
.memo-window{
position:fixed;
z-index:10000;
width:300px;
height:430px;
left:50%;
top:50%;
transform:translate(-50%,-50%);
background:var(--panelBg);
color:var(--panelText);
border:1px solid #999;
border-radius:10px;
box-shadow:0 8px 30px #0005;
display:flex;
flex-direction:column;
overflow:hidden;
}
.memo-window-header{
height:42px;
flex:0 0 42px;
display:flex;
align-items:center;
justify-content:space-between;
padding:0 8px 0 12px;
background:var(--main);
color:white;
cursor:move;
user-select:none;
}
.memo-window-title{
font-weight:bold;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.memo-window-close{
width:30px;
height:30px;
border:0;
border-radius:6px;
background:transparent;
color:white;
font-size:22px;
line-height:30px;
cursor:pointer;
padding:0;
}
.memo-window-close:hover{
background:#ffffff33;
}
.memo-window-body{
flex:1;
min-height:0;
padding:12px;
display:flex;
}
.memo-window-body textarea{
width:100%;
height:100%;
min-height:0;
resize:none;
margin:0;
font-family:var(--commentFont);
font-size:var(--commentSize);
line-height:1.7;
}
.memo-view-grid{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:8px;
margin:10px 0 18px;
}
.memo-view-item{
min-width:0;
height:38px;
box-sizing:border-box;
padding:8px 10px;
border:1px solid var(--border);
border-radius:7px;
background:var(--panelBg);
color:var(--panelText);
font-family:var(--commentFont);
font-size:var(--commentSize);
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
/* イメージカラー白:ライトモード専用 */
body.image-white:not(.dark-mode) header {
color: #000;
}
body.image-white:not(.dark-mode) .side-btn.active {
background: #000;
color: #fff;
border: 1px solid #000;
}
body.image-white:not(.dark-mode) .trip-date {
color: #000 !important;
border-color: #000 !important;
}
body.image-white:not(.dark-mode) .photo-count-editor-button.selected,
body.image-white:not(.dark-mode) .save,
body.image-white:not(.dark-mode) .dark-mode-button.selected,
body.image-white:not(.dark-mode) .size-button.selected,
body.image-white:not(.dark-mode) .transparency-button.selected {
background: #000;
color: #fff;
border: 1px solid #000;
}
body.image-white:not(.dark-mode) .settings-section h2,
body.image-white:not(.dark-mode) .photo-count-editor-title,
body.image-white:not(.dark-mode) .drop-zone {
color: #000;
}
body.image-white:not(.dark-mode) .action.save {
border: 1px solid #000;
}
@media(max-width:700px){
.memo-view-grid{
grid-template-columns:repeat(4,minmax(0,1fr));
gap:5px;
}
.memo-view-item{
padding:7px 5px;
font-size:12px;
}
}
@media(max-width:700px){
.memo-window{
width:280px;
height:400px;
}
}
.photo-window img{max-width:95vw;max-height:95vh;object-fit:contain}
.storage-info{
padding:10px;margin:10px 0;border-radius:8px;background:#eef5ff;
color:#315b8a;font-size:14px
}
/* =========================
ダークモード
背景カラーそのものには適用しない
========================= */
body.dark-mode{
--text:#eeeeee;
--panelText:#eeeeee;
--inputBg:#292929;
--inputText:#eeeeee;
--border:#555;
--light:#303b48;
--panelBg:rgba(30,30,30,.96);
}
/* ダークモード:指定した入力欄の案内文字だけ明るくする */
body.dark-mode #tripTitle::placeholder,
body.dark-mode #tripStation::placeholder,
body.dark-mode #tripTravelMinutes::placeholder,
body.dark-mode [id^="tripPurpose"]::placeholder,
body.dark-mode #tripText::placeholder,
body.dark-mode input[type="search"]::placeholder{
color:#bdbdbd;
opacity:1;
}
body.dark-mode .sidebar{
border-right-color:#444
}
body.dark-mode .side-btn{
color:#eeeeee
}
body.dark-mode .side-btn:hover{
background:#303b48
}
body.dark-mode .panel,
body.dark-mode .detail,
body.dark-mode .settings-section{
box-shadow:0 2px 9px #0008
}
body.dark-mode .cancel{
background:#555;
color:#eee
}
body.dark-mode .trip{
border-bottom-color:#444
}
body.dark-mode .trip-date{
color:#aaa
}
body.dark-mode .photo-count-editor{
border-color:#444;
background:rgba(45,45,45,.8)
}
body.dark-mode .photo-count-editor-button,
body.dark-mode .year-buttons button,
body.dark-mode .size-button,
body.dark-mode .font-choice,
body.dark-mode .transparency-button,
body.dark-mode .dark-mode-button{
border-color:#555
}
body.dark-mode .font-choice-name{
color:#ccc
}
body.dark-mode .size-setting{
border-bottom-color:#444
}
body.dark-mode .detail-text{
border-color:#444
}
body.dark-mode .photo-item,
body.dark-mode .edit-photo{
border-color:#666
}
body.dark-mode .edit-comment-box{
border-color:#555
}
body.dark-mode .storage-info{
background:#26384c;
color:#c8ddf5
}
/* イメージカラー白:ダークモード時の選択ボタン文字色 */
body.image-white.dark-mode .photo-count-editor-button.selected,
body.image-white.dark-mode .save,
body.image-white.dark-mode .dark-mode-button.selected,
body.image-white.dark-mode .size-button.selected,
body.image-white.dark-mode .transparency-button.selected {
color: #000 !important;
}
/* イメージカラー黒 × ダークモード:文字色 */
body.dark-mode.image-black .trip-date {
color: #fff !important;
}
/* 設定・背景画面の見出しと説明 */
body.dark-mode.image-black .settings-section h2,
body.dark-mode.image-black .settings-section p,
body.dark-mode.image-black .settings-section label {
color: #fff !important;
}
/* 説明書の見出し */
body.dark-mode.image-black .app-description h2,
body.dark-mode.image-black .app-description h3 {
color: #fff !important;
}
@media(max-width:700px){
.layout{display:block}
.sidebar{
width:100%;flex-direction:row;overflow-x:auto;
border-right:0;border-bottom:1px solid #ddd
}
.side-btn{width:auto;min-width:max-content;white-space:nowrap}
main{padding:12px}
.trip{display:block}
.trip-date{width:auto;margin-bottom:4px}
.photo-grid,.edit-photo-grid{
grid-template-columns:repeat(var(--photoColumns),minmax(0,1fr));
}
.editor-heading-title{font-size:22px}
.date-row{align-items:center;flex-wrap:nowrap}
.date-row input{min-width:0;width:auto}
.weekday-display{flex-shrink:0}
}
/* =========================
起動時・フォルダアクセス許可画面
========================= */
.permission-screen{
min-height:100vh;
width:100%;
display:flex;
align-items:center;
justify-content:center;
position:relative;
overflow:hidden;
padding:30px;
background:
radial-gradient(
circle at 15% 25%,
rgba(80,140,255,.42),
transparent 35%
),
radial-gradient(
circle at 85% 20%,
rgba(155,90,255,.36),
transparent 35%
),
radial-gradient(
circle at 70% 85%,
rgba(60,210,220,.28),
transparent 40%
),
linear-gradient(
135deg,
#eaf2ff 0%,
#edf0ff 45%,
#f4edff 100%
);
}
.permission-screen::before{
content:"";
position:absolute;
width:420px;
height:420px;
border-radius:50%;
background:rgba(255,255,255,.35);
filter:blur(2px);
top:-180px;
right:-120px;
}
.permission-screen::after{
content:"";
position:absolute;
width:320px;
height:320px;
border-radius:50%;
background:rgba(79,134,198,.08);
filter:blur(4px);
bottom:-160px;
left:-100px;
}
.permission-card{
position:relative;
z-index:1;
width:min(520px,100%);
padding:38px 34px;
border:1px solid rgba(255,255,255,.8);
border-radius:24px;
background:rgba(255,255,255,.78);
box-shadow:
0 20px 60px rgba(40,70,100,.16),
0 4px 16px rgba(0,0,0,.06);
backdrop-filter:blur(14px);
-webkit-backdrop-filter:blur(14px);
text-align:center;
}
.permission-icon{
width:72px;
height:72px;
margin:0 auto 20px;
display:flex;
align-items:center;
justify-content:center;
border-radius:20px;
background:linear-gradient(135deg,var(--main),#76a9df);
color:white;
font-size:34px;
box-shadow:0 10px 25px rgba(79,134,198,.28);
}
.permission-card h2{
margin:0 0 14px;
color:var(--main);
font-family:var(--settingFont);
font-size:var(--settingSize);
font-weight:bold;
}
.permission-card p{
margin:0 0 24px;
color:#666;
font-family:var(--settingFont);
font-size:var(--settingSize);
line-height:1.8;
}
.permission-card .action{
margin:0;
}
body.dark-mode .permission-screen{
background:
radial-gradient(
circle at 20% 20%,
rgba(79,134,198,.25),
transparent 35%
),
radial-gradient(
circle at 80% 80%,
rgba(112,173,71,.12),
transparent 35%
),
linear-gradient(
135deg,
#171d25,
#20262e 50%,
#18211d
);
}
body.dark-mode .permission-card{
background:rgba(30,30,30,.78);
border-color:rgba(255,255,255,.08);
box-shadow:0 20px 60px rgba(0,0,0,.35);
}
body.dark-mode .permission-card p{
color:#bbb;
}
</style>
</head>
<body>
<header><h1 id="appTitle">記録</h1></header>
<div class="layout">
<aside class="sidebar">
<button class="side-btn" data-page="yearRecords">📅 年別</button>
<button class="side-btn active" data-page="years">📋 一覧</button>
<button class="side-btn" data-page="add">➕ 編集</button>
<button class="side-btn" data-page="settings">⚙ 設定</button>
<button class="side-btn" data-page="background">🖼 背景</button>
<button class="side-btn" data-page="appDescription">📖 説明書</button>
<div class="sidebar-space"></div>
<button class="side-btn" data-page="initialize">🗑 記録初期化</button>
</aside>
<main id="main"></main>
</div>
<script>
"use strict";
/* =========================
保存キー
========================= */
const PHOTOS_DIR="photos";
const BACKGROUND_DIR="background";
let recordFolderHandle=null;
const DB_NAME="record_v3_image_db";
const DB_VERSION=1;
const IMAGE_STORE="images";
let dbPromise=null;
function openDB(){
if(dbPromise)return dbPromise;
dbPromise=new Promise((resolve,reject)=>{
if(!window.indexedDB){
reject(new Error("このブラウザはIndexedDBに対応していません。"));
return;
}
const r=indexedDB.open(DB_NAME,DB_VERSION);
r.onerror=()=>reject(r.error||new Error("IndexedDBを開けませんでした。"));
r.onupgradeneeded=e=>{
const db=e.target.result;
if(!db.objectStoreNames.contains(IMAGE_STORE))
db.createObjectStore(IMAGE_STORE);
};
r.onsuccess=()=>resolve(r.result);
});
return dbPromise;
}
async function idbPut(key,value){
const db=await openDB();
return new Promise((resolve,reject)=>{
const tx=db.transaction(IMAGE_STORE,"readwrite");
tx.objectStore(IMAGE_STORE).put(value,key);
tx.oncomplete=resolve;
tx.onerror=()=>reject(tx.error);
});
}
async function idbGet(key){
const db=await openDB();
return new Promise((resolve,reject)=>{
const tx=db.transaction(IMAGE_STORE,"readonly");
const r=tx.objectStore(IMAGE_STORE).get(key);
r.onsuccess=()=>resolve(r.result);
r.onerror=()=>reject(r.error);
});
}
async function getPhotosFolder(){
if(!recordFolderHandle){
throw new Error("記録管理フォルダが選択されていません。");
}
return await recordFolderHandle.getDirectoryHandle(
PHOTOS_DIR,
{create:true}
);
}
async function savePhotoToFolder(imageId,blob){
const folder=await getPhotosFolder();
const fileHandle=await folder.getFileHandle(
imageId+".jpg",
{create:true}
);
const writable=await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
}
async function loadPhotoFromFolder(imageId){
const folder=await getPhotosFolder();
const fileHandle=await folder.getFileHandle(
imageId+".jpg"
);
return await fileHandle.getFile();
}
async function deletePhotoFromFolder(imageId){
if(!imageId)return;
try{
const folder=await getPhotosFolder();
await folder.removeEntry(
imageId+".jpg"
);
}catch(e){
if(e.name!=="NotFoundError"){
console.error("写真削除エラー:",e);
throw e;
}
}
}
/* =========================
設定
========================= */
const DEFAULT_COLORS=[
"#4f86c6","#5b9bd5","#70ad47","#ed7d31","#a64d79",
"#8064a2","#00a6a6","#c0504d","#fef08a","#808080",
"#f01818","#ff4f9a","#9b35e8","#00b894","#333333",
"#ffffff"
];
const FONTS=[
["游ゴシック","Yu Gothic"],
["メイリオ","Meiryo"],
["BIZ UDPゴシック","'BIZ UDPGothic',sans-serif"],
["BIZ UDP明朝","'BIZ UDMincho',serif"],
["UD デジタル 教科書体","'UD Digi Kyokasho NK-R',sans-serif"],
["MS P ゴシック","MS PGothic"],
["MS P 明朝","MS PMincho"]
];
/*
ここは写真の「最大枚数」ではなく、
「1行あたりの列数」。
*/
const PHOTO_COLUMNS={2:2,3:3,4:4,5:5,6:6,8:8,10:10};
const PHOTO_COUNT_OPTIONS=[10,8,6,5,4,3,2];
const DEFAULT_PHOTO_COLUMNS=3;
const DEFAULT_SETTINGS={
title:"記録",
colors:[...DEFAULT_COLORS],
colorIndex:0,
backgroundColors:[...DEFAULT_COLORS],
backgroundColorIndex:0,
darkMode:false,
titleSize:3,
dateSize:2,
yearSize:2,
editSize:2,
bodySize:2,
commentSize:2,
listSize:2,
settingSize:2,
titleFont:"游ゴシック",
dateFont:"游ゴシック",
yearFont:"游ゴシック",
editFont:"游ゴシック",
bodyFont:"游ゴシック",
commentFont:"游ゴシック",
listFont:"游ゴシック",
settingFont:"游ゴシック",
font:"游ゴシック",
titleStyle:"classic",
photoColumns:DEFAULT_PHOTO_COLUMNS,
panelTransparency:1,
backgroundImageId:null
};
function normalizePhotoColumns(v){
const n=Number(v);
return PHOTO_COLUMNS[n]?n:DEFAULT_PHOTO_COLUMNS;
}
function setPhotoColumns(n){
n=normalizePhotoColumns(n);
document.documentElement.style.setProperty("--photoColumns",n);
return n;
}
function normalizeColors(colors){
let result=Array.isArray(colors)?[...colors]:[];
result=result.map(c=>c==="#fff2a8"?"#d9a900":c);
DEFAULT_COLORS.forEach(c=>{
if(!result.includes(c))result.push(c);
});
return result;
}
async function loadSettings(){
try{
if(!recordFolderHandle){
return {
...DEFAULT_SETTINGS,
colors:[...DEFAULT_COLORS],
backgroundColors:[...DEFAULT_COLORS]
};
}
const fileHandle=await recordFolderHandle.getFileHandle(
"settings.json"
);
const file=await fileHandle.getFile();
const raw=await file.text();
const data=JSON.parse(raw);
const colors=normalizeColors(data.colors);
const backgroundColors=normalizeColors(
Array.isArray(data.backgroundColors)
?data.backgroundColors
:colors
);
const s={
...DEFAULT_SETTINGS,
...data,
colors,
backgroundColors
};
const old=data.font||"游ゴシック";
[
"titleFont",
"dateFont",
"editFont",
"bodyFont",
"commentFont",
"listFont",
"settingFont"
].forEach(k=>{
if(
!data[k] ||
!FONTS.some(f=>f[0]===data[k])
){
s[k]=old;
}
});
s.titleStyle="classic";
if(s.colorIndex<0||s.colorIndex>=s.colors.length)s.colorIndex=0;
if(
s.backgroundColorIndex<0||
s.backgroundColorIndex>=s.backgroundColors.length
)s.backgroundColorIndex=0;
s.darkMode=!!s.darkMode;
s.backgroundImageId=s.backgroundImageId||null;
backgroundImageId=s.backgroundImageId;
return s;
}catch(e){
console.error(e);
return {
...DEFAULT_SETTINGS,
colors:[...DEFAULT_COLORS],
backgroundColors:[...DEFAULT_COLORS]
};
}
}
async function saveSettings(){
try{
if(!recordFolderHandle){
alert("記録管理フォルダを選択してください。");
return false;
}
let permission=await recordFolderHandle.queryPermission({
mode:"readwrite"
});
if(permission!=="granted"){
permission=await recordFolderHandle.requestPermission({
mode:"readwrite"
});
}
if(permission!=="granted"){
alert("記録管理フォルダへの書き込みを許可してください。");
return false;
}
const fileHandle=await recordFolderHandle.getFileHandle(
"settings.json",
{create:true}
);
settings.backgroundImageId=backgroundImageId||null;
const writable=await fileHandle.createWritable();
await writable.write(JSON.stringify(settings,null,2));
await writable.close();
return true;
}catch(e){
console.error(e);
alert("設定を保存できませんでした。");
return false;
}
}
async function selectRecordFolder(){
try{
recordFolderHandle=await window.showDirectoryPicker({
mode:"readwrite"
});
const permission=await recordFolderHandle.requestPermission({
mode:"readwrite"
});
if(permission!=="granted"){
recordFolderHandle=null;
alert("記録管理フォルダへのアクセスを許可してください。");
return false;
}
await idbPut("__record_folder_handle__",recordFolderHandle);
return true;
}catch(e){
if(e.name==="AbortError")return false;
console.error(e);
alert("記録管理フォルダを選択できませんでした。");
return false;
}
}
/* =========================
記録データ
========================= */
function normalizeTrips(list){
return (Array.isArray(list)?list:[]).map(t=>({
id:t.id||Date.now()+"_"+Math.random(),
title:t.title||"",
date:t.date||"",
text:t.text||"",
rating:t.rating??"",
access:t.access??"",
prefecture:t.prefecture||"",
station:t.station||"",
travelMinutes:t.travelMinutes??"",
purposes:[1,2,3].map((n,i)=>{
const p=Array.isArray(t.purposes)
?(t.purposes[i]||{})
:{};
return {
purpose:p.purpose||"",
startMonth:p.startMonth||"",
startPeriod:p.startPeriod||"",
endMonth:p.endMonth||"",
endPeriod:p.endPeriod||""
};
}),
memos:Array.isArray(t.memos)
?[...t.memos,"","","","","","",""].slice(0,8)
:["","","","","","","",""],
photoColumns:normalizePhotoColumns(t.photoColumns),
lastEdited:t.lastEdited||"",
photos:Array.isArray(t.photos)
?t.photos.filter(p=>p&&p.imageId).map(p=>({
id:p.id||Date.now()+"_"+Math.random(),
imageId:p.imageId,
comment:p.comment||""
}))
:[]
}));
}
async function loadTrips(){
try{
if(!recordFolderHandle)return [];
const fileHandle=await recordFolderHandle.getFileHandle(
"records.json"
);
const file=await fileHandle.getFile();
const raw=await file.text();
return raw?normalizeTrips(JSON.parse(raw)):[];
}catch(e){
console.error(e);
return [];
}
}
async function saveTrips(){
try{
if(!recordFolderHandle){
alert("記録管理フォルダを選択してください。");
return false;
}
let permission=await recordFolderHandle.queryPermission({
mode:"readwrite"
});
if(permission!=="granted"){
permission=await recordFolderHandle.requestPermission({
mode:"readwrite"
});
}
if(permission!=="granted"){
alert("記録管理フォルダへの書き込みを許可してください。");
return false;
}
const fileHandle=await recordFolderHandle.getFileHandle(
"records.json",
{create:true}
);
const writable=await fileHandle.createWritable();
await writable.write(
JSON.stringify(trips,null,2)
);
await writable.close();
return true;
}catch(e){
console.error("saveTrips error:",e);
alert(
"保存できませんでした。\n\n"+
"records.json の作成・保存に失敗しました。\n"+
e.name+": "+e.message
);
return false;
}
}
async function initializeRecords(){
const first=confirm(
"記録を初期化します。\n\n"+
"すべての記録と、記録に保存されている写真が削除されます。\n\n"+
"設定・背景画像・背景画像の設定は削除されません。\n\n"+
"本当に実行しますか?"
);
if(!first)return;
const second=confirm(
"最終確認です。\n\n"+
"記録と記録に紐づく写真をすべて削除します。\n"+
"この操作は元に戻せません。\n\n"+
"実行しますか?"
);
if(!second)return;
try{
for(const trip of trips){
if(!Array.isArray(trip.photos))continue;
for(const photo of trip.photos){
if(photo && photo.imageId){
await deletePhotoFromFolder(photo.imageId);
}
}
}
trips=[];
if(!(await saveTrips())){
return;
}
showYears();
alert("記録と写真を初期化しました。");
}catch(e){
console.error("記録初期化エラー:",e);
alert(
"記録の初期化中にエラーが発生しました。\n\n"+
e.name+": "+e.message
);
}
}
/* =========================
下書き
========================= */
let draftTimer = null;
function saveDraft() {
clearTimeout(draftTimer);
draftTimer = setTimeout(async () => {
try {
// 編集画面がなくなっていたら何もしない。
// 画面遷移後に下書きを削除するのを防ぐ。
if (!document.getElementById("tripTitle")) {
return;
}
const d = getCurrentDraft();
if (!d || !recordFolderHandle) {
return;
}
let permission = await recordFolderHandle.queryPermission({
mode: "readwrite"
});
if (permission !== "granted") {
permission = await recordFolderHandle.requestPermission({
mode: "readwrite"
});
}
if (permission !== "granted") {
console.error("下書き保存の権限がありません。");
return;
}
const fileHandle = await recordFolderHandle.getFileHandle(
"draft.json",
{ create: true }
);
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(d, null, 2));
await writable.close();
} catch (e) {
console.error("下書き保存エラー:", e);
}
}, 300);
}
function getCurrentDraft(){
const getValue=id=>
document.getElementById(id)?.value||"";
const title=getValue("tripTitle");
const date=getValue("tripDate");
const text=getValue("tripText");
const rating=getValue("tripRating");
const access=getValue("tripAccess");
const prefecture=getValue("tripPrefecture");
const station=getValue("tripStation");
const travelMinutes=getValue("tripTravelMinutes");
const purposes=[1,2,3].map(n=>({
purpose:getValue("tripPurpose"+n),
startMonth:getValue("tripStartMonth"+n),
startPeriod:getValue("tripStartPeriod"+n),
endMonth:getValue("tripEndMonth"+n),
endPeriod:getValue("tripEndPeriod"+n)
}));
const hasTravelInfo=
prefecture||
station||
travelMinutes||
purposes.some(p=>
p.purpose||
p.startMonth||
p.startPeriod||
p.endMonth||
p.endPeriod
);
const hasMemos=Array.isArray(editingMemos)
&&editingMemos.some(m=>String(m||"").trim()!=="");
if(
!title&&!date&&!text&&!rating&&!access&&!hasTravelInfo&&
!editingPhotos.length&&!hasMemos
){
return null;
}
return {
editingId,
title,
date,
text,
rating,
access,
prefecture,
station,
travelMinutes,
purposes,
memos:Array.isArray(editingMemos)
?[...editingMemos,"","","","","","",""].slice(0,8)
:["","","","","","","",""],
photoColumns:normalizePhotoColumns(editingPhotoColumns),
photos:editingPhotos.map(p=>({
id:p.id,
imageId:p.imageId,
comment:p.comment||""
})),
savedAt:new Date().toISOString()
};
}
async function saveDraftBeforeNavigation(){
// 遅延保存が画面遷移後に動くのを防ぐ
clearTimeout(draftTimer);
draftTimer = null;
const d=getCurrentDraft();
if(!d){
await clearDraft();
return;
}
// 編集中の候補が保存済みデータと同じか確認
const original=trips.find(
t=>String(t.id)===String(editingId)
);
if(original && String(d.editingId)===String(original.id)){
const normalizePurposes=items=>
[0,1,2].map(i=>{
const p=(items||[])[i]||{};
return {
purpose:String(p.purpose||""),
startMonth:String(p.startMonth||""),
startPeriod:String(p.startPeriod||""),
endMonth:String(p.endMonth||""),
endPeriod:String(p.endPeriod||"")
};
});
const normalizeMemos=items=>
[...(Array.isArray(items)?items:[]),"","","","","","",""]
.slice(0,8)
.map(v=>String(v||""));
const normalizePhotos=items=>
(Array.isArray(items)?items:[]).map(p=>({
id:p.id,
imageId:p.imageId,
comment:p.comment||""
}));
const current={
title:String(d.title||""),
date:String(d.date||""),
text:String(d.text||""),
prefecture:String(d.prefecture||""),
station:String(d.station||""),
travelMinutes:String(d.travelMinutes||""),
purposes:normalizePurposes(d.purposes),
memos:normalizeMemos(d.memos),
photoColumns:normalizePhotoColumns(d.photoColumns),
photos:normalizePhotos(d.photos)
};
const saved={
title:String(original.title||""),
date:String(original.date||""),
text:String(original.text||""),
prefecture:String(original.prefecture||""),
station:String(original.station||""),
travelMinutes:String(original.travelMinutes||""),
purposes:normalizePurposes(original.purposes),
memos:normalizeMemos(original.memos),
photoColumns:normalizePhotoColumns(original.photoColumns),
photos:normalizePhotos(original.photos)
};
if(JSON.stringify(current)===JSON.stringify(saved)){
await clearDraft();
return;
}
}
// 変更がある場合は従来どおり下書きを保存
try{
if(!recordFolderHandle)return;
let permission=await recordFolderHandle.queryPermission({
mode:"readwrite"
});
if(permission!=="granted"){
permission=await recordFolderHandle.requestPermission({
mode:"readwrite"
});
}
if(permission!=="granted")return;
const fileHandle=await recordFolderHandle.getFileHandle(
"draft.json",
{create:true}
);
const writable=await fileHandle.createWritable();
await writable.write(
JSON.stringify(d,null,2)
);
await writable.close();
} catch (e) {
console.error("画面移動前の下書き保存エラー:", e);
// 呼び出し元に失敗を通知し、画面移動を中止できるようにする
throw e;
}
}
async function clearDraft(){
clearTimeout(draftTimer);
try{
if(!recordFolderHandle)return;
let permission=await recordFolderHandle.queryPermission({
mode:"readwrite"
});
if(permission!=="granted"){
permission=await recordFolderHandle.requestPermission({
mode:"readwrite"
});
}
if(permission!=="granted")return;
try{
await recordFolderHandle.removeEntry("draft.json");
}catch(e){
if(e.name!=="NotFoundError"){
throw e;
}
}
}catch(e){
console.error("下書き削除エラー:",e);
}
}
async function loadDraft(){
try{
if(!recordFolderHandle)return null;
let permission=await recordFolderHandle.queryPermission({
mode:"read"
});
if(permission!=="granted"){
permission=await recordFolderHandle.requestPermission({
mode:"read"
});
}
if(permission!=="granted")return null;
const fileHandle=await recordFolderHandle.getFileHandle(
"draft.json"
);
const file=await fileHandle.getFile();
const raw=await file.text();
return raw ? JSON.parse(raw) : null;
}catch(e){
if(e.name!=="NotFoundError"){
console.error("下書き読み込みエラー:",e);
}
return null;
}
}
/* =========================
状態
========================= */
let settings={...DEFAULT_SETTINGS};
let trips=[];
let editingId=null;
let editingPhotos=[];
let editingPhotoColumns=DEFAULT_PHOTO_COLUMNS;
let saveLock=false;
let backgroundImageId=null;
let backgroundObjectUrl=null;
let editingMemos=["","","","","","","",""];
/* =========================
ナビ
========================= */
document.querySelectorAll(".side-btn").forEach(b => {
b.onclick = async () => {
const p = b.dataset.page;
// 画面を切り替える前に、編集中の下書きを保存する
if (editingId !== null || document.getElementById("tripTitle")) {
try {
await saveDraftBeforeNavigation();
} catch (e) {
console.error("画面移動前の下書き保存エラー:", e);
alert(
"下書きを保存できませんでした。\n" +
"編集内容を失わないよう、画面移動を中止しました。\n\n" +
e.message
);
return;
}
}
// 保存処理の完了後に画面を切り替える
if (p === "yearRecords") showYearRecords();
if (p === "years") showYears();
if (p === "add") showAdd();
if (p === "settings") showSettings();
if (p === "appDescription") showAppDescription();
if (p === "background") showBackground();
if (p === "initialize") {
await initializeRecords();
}
};
});
function setActive(page){
document.querySelectorAll(".side-btn").forEach(b=>
b.classList.toggle("active",b.dataset.page===page)
);
}
/* =========================
一覧
========================= */
function showYears(){
setActive("years");
const main=document.getElementById("main");
main.innerHTML="";
const panel=document.createElement("div");
panel.className="panel";
const monthSet=new Set();
const timeSet=new Set();
function eachPurpose(t){
return Array.isArray(t.purposes)?t.purposes:[];
}
function purposeMonths(p){
const start=Number(p.startMonth);
const end=Number(p.endMonth);
if(!start)return [];
if(!end)return [start];
const result=[];
let m=start;
for(let i=0;i<12;i++){
result.push(m);
if(m===end)break;
m=m%12+1;
}
return result;
}
trips.forEach(t=>{
eachPurpose(t).forEach(p=>{
purposeMonths(p).forEach(m=>monthSet.add(m));
});
if(t.travelMinutes!==""&&t.travelMinutes!=null){
const n=Number(t.travelMinutes);
if(Number.isFinite(n)&&n>=0)timeSet.add(n);
}
});
const months=[...monthSet].sort((a,b)=>a-b);
const maxTime=timeSet.size?Math.max(...timeSet):0;
const timeRanges=[];
for(let start=0;start<=maxTime;){
const end=start===0?15:start+14;
if([...timeSet].some(n=>n>=start&&n<=end)){
timeRanges.push({start,end});
}
start=start===0?16:start+15;
}
const selectedMonths=new Set();
const selectedTimes=new Set();
let searchText="";
let monthOpen=false;
let timeOpen=false;
const filterBar=document.createElement("div");
filterBar.style.display="flex";
filterBar.style.flexWrap="wrap";
filterBar.style.alignItems="center";
filterBar.style.gap="8px";
filterBar.style.marginBottom="14px";
function makeButton(label,onClick){
const b=document.createElement("button");
b.type="button";
b.textContent=label;
b.style.padding="8px 12px";
b.style.fontFamily="var(--dateFont)";
b.style.fontSize="var(--dateSize)";
b.style.border="1px solid var(--border)";
b.style.borderRadius="7px";
b.style.background="var(--panelBg)";
b.style.color="var(--panelText)";
b.style.cursor="pointer";
b.onclick=onClick;
return b;
}
const monthButton=makeButton("時期 ▽",()=>{
monthOpen=!monthOpen;
renderFilters();
});
const monthClear=makeButton("✕",()=>{
selectedMonths.clear();
monthOpen=false;
renderFilters();
renderRows();
});
const timeButton=makeButton("時間 ▽",()=>{
timeOpen=!timeOpen;
renderFilters();
});
monthClear.style.marginRight="15px";
const timeClear=makeButton("✕",()=>{
selectedTimes.clear();
timeOpen=false;
renderFilters();
renderRows();
});
timeClear.style.marginRight="30px";
const search=document.createElement("input");
search.type="search";
search.placeholder="検索";
search.style.flex="1";
search.style.minWidth="120px";
search.style.fontFamily="var(--dateFont)";
search.style.fontSize="var(--dateSize)";
search.style.margin="0";
search.addEventListener("input",()=>{
searchText=search.value.trim().toLowerCase();
renderRows();
});
const count=document.createElement("span");
count.style.whiteSpace="nowrap";
filterBar.append(
monthButton,monthClear,timeButton,timeClear,search,count
);
panel.appendChild(filterBar);
const monthOptions=document.createElement("div");
monthOptions.style.display="none";
monthOptions.style.flexWrap="wrap";
monthOptions.style.gap="6px";
monthOptions.style.marginBottom="12px";
const timeOptions=document.createElement("div");
timeOptions.style.display="none";
timeOptions.style.flexWrap="wrap";
timeOptions.style.gap="6px";
timeOptions.style.marginBottom="12px";
panel.append(monthOptions,timeOptions);
const list=document.createElement("div");
list.className="trip-list";
panel.appendChild(list);
function renderFilters(){
monthButton.textContent=monthOpen?"時期 ▲":"時期 ▽";
timeButton.textContent=timeOpen?"時間 ▲":"時間 ▽";
const whiteImage =
document.body.classList.contains("image-white");
const darkMode =
document.body.classList.contains("dark-mode");
const whiteImageDark = whiteImage && darkMode;
const whiteImageLight = whiteImage && !darkMode;
monthButton.style.background =
monthOpen && whiteImageDark ? "#fff" :
monthOpen && whiteImageLight ? "#000" :
monthOpen ? "var(--main)" : "var(--panelBg)";
monthButton.style.color =
monthOpen && whiteImageDark ? "#000" :
monthOpen && whiteImageLight ? "#fff" :
monthOpen ? "white" : "var(--panelText)";
monthButton.style.borderColor =
monthOpen && whiteImageDark ? "#fff" :
monthOpen && whiteImageLight ? "#000" :
"var(--border)";
timeButton.style.background =
timeOpen && whiteImageDark ? "#fff" :
timeOpen && whiteImageLight ? "#000" :
timeOpen ? "var(--main)" : "var(--panelBg)";
timeButton.style.color =
timeOpen && whiteImageDark ? "#000" :
timeOpen && whiteImageLight ? "#fff" :
timeOpen ? "white" : "var(--panelText)";
timeButton.style.borderColor =
timeOpen && whiteImageDark ? "#fff" :
timeOpen && whiteImageLight ? "#000" :
"var(--border)";
monthOptions.style.display=monthOpen?"flex":"none";
timeOptions.style.display=timeOpen?"flex":"none";
monthOptions.innerHTML="";
months.forEach(m=>{
const b=makeButton(String(m).padStart(2,"0")+"月",()=>{
if(selectedMonths.has(m))selectedMonths.delete(m);
else selectedMonths.add(m);
renderFilters();
renderRows();
});
if(selectedMonths.has(m)){
const whiteImage =
document.body.classList.contains("image-white");
const darkMode =
document.body.classList.contains("dark-mode");
b.style.background =
whiteImage && !darkMode ? "#000" :
whiteImage && darkMode ? "#fff" :
"var(--main)";
b.style.color =
whiteImage && darkMode ? "#000" : "#fff";
b.style.border =
whiteImage
? `1px solid ${darkMode ? "#fff" : "#000"}`
: "1px solid var(--border)";
}
monthOptions.appendChild(b);
});
timeOptions.innerHTML="";
timeRanges.forEach(r=>{
const key=r.start+"-"+r.end;
const label=String(r.start).padStart(2,"0")+"-"+
String(r.end).padStart(2,"0")+"分";
const b=makeButton(label,()=>{
if(selectedTimes.has(key))selectedTimes.delete(key);
else selectedTimes.add(key);
renderFilters();
renderRows();
});
if(selectedTimes.has(key)){
const whiteImage =
document.body.classList.contains("image-white");
const darkMode =
document.body.classList.contains("dark-mode");
b.style.background =
whiteImage && !darkMode ? "#000" :
whiteImage && darkMode ? "#fff" :
"var(--main)";
b.style.color =
whiteImage && darkMode ? "#000" : "#fff";
b.style.border =
whiteImage
? `1px solid ${darkMode ? "#fff" : "#000"}`
: "1px solid var(--border)";
}
timeOptions.appendChild(b);
});
}
function matchesMonth(t){
if(!selectedMonths.size)return true;
return eachPurpose(t).some(p=>
purposeMonths(p).some(m=>selectedMonths.has(m))
);
}
function matchesTime(t){
if(!selectedTimes.size)return true;
if(t.travelMinutes===""||t.travelMinutes==null)return false;
const n=Number(t.travelMinutes);
return [...selectedTimes].some(key=>{
const [start,end]=key.split("-").map(Number);
return n>=start&&n<=end;
});
}
function matchesSearch(t){
if(!searchText)return true;
const values=[
t.title,t.prefecture,t.station,t.text,
...eachPurpose(t).map(p=>p.purpose)
];
return values.some(v=>
String(v||"").toLowerCase().includes(searchText)
);
}
function renderRows(){
list.innerHTML="";
const filtered=trips.filter(t=>
!t.date &&
matchesMonth(t)&&
matchesTime(t)&&
matchesSearch(t)
);
filtered.sort((a,b)=>{
function order(t){
const p=eachPurpose(t).find(x=>x.startMonth);
return p
?Number(p.startMonth)*100+
({"上旬":1,"中旬":2,"下旬":3}[p.startPeriod]||0)
:9999;
}
return order(a)-order(b);
});
count.textContent=filtered.length+"件";
if(!filtered.length){
const empty=document.createElement("p");
empty.textContent="該当する候補がありません。";
list.appendChild(empty);
return;
}
filtered.forEach(t=>{
const row=document.createElement("div");
row.className="trip";
row.style.display="grid";
row.style.gridTemplateColumns="max-content 85px minmax(0,1fr)";
row.style.columnGap="8px";
row.style.alignItems="center";
row.style.alignItems="center";
const p=eachPurpose(t).find(x=>
x.startMonth||x.startPeriod
);
const period=document.createElement("div");
period.className="trip-date";
period.style.width="auto";
period.style.minWidth="0";
period.textContent=p&&p.startMonth
?String(p.startMonth).padStart(2,"0")+"月"+
(p.startPeriod||"")+"~"
:"時期未設定";
period.style.fontWeight="600";
period.style.color="var(--main)";
period.style.border="1px solid var(--main)";
period.style.borderRadius="5px";
period.style.padding="2px 4px";
period.style.width="max-content";
period.style.maxWidth="none";
period.style.whiteSpace="nowrap";
const time=document.createElement("div");
time.className="trip-date";
time.style.width="auto";
time.style.minWidth="0";
time.style.textAlign="center";
time.textContent=t.travelMinutes!==""&&t.travelMinutes!=null
?String(t.travelMinutes).padStart(3,"0")+" 分"
:"--- 分";
time.style.fontWeight="600";
time.style.color="var(--main)";
time.style.border="1px solid var(--main)";
time.style.borderRadius="5px";
time.style.padding="2px 4px";
time.style.width="85px";
const title=document.createElement("div");
title.className="trip-title";
title.style.minWidth="0";
title.style.whiteSpace="nowrap";
title.style.overflow="hidden";
title.style.textOverflow="ellipsis";
title.textContent=t.title||"";
row.append(period,time,title);
title.style.marginLeft="0";
row.onclick=()=>editTrip(t.id);
list.appendChild(row);
});
}
renderFilters();
renderRows();
main.appendChild(panel);
}
function showYearRecords(){
setActive("yearRecords");
const main=document.getElementById("main");
main.innerHTML="";
const panel=document.createElement("div");
panel.className="panel";
panel.style.background=document.body.classList.contains("dark-mode")
?"linear-gradient(135deg,#252a31,#1e1e1e)"
:"linear-gradient(135deg,#edf5ff,#ffffff)";
panel.style.border="2px solid #7da7d9";
panel.style.borderRadius="12px";
panel.style.padding="16px";
const records=trips.filter(t=>
t.date && /^\d{4}-\d{2}-\d{2}$/.test(t.date)
);
const years=[...new Set(records.map(t=>t.date.slice(0,4)))]
.sort((a,b)=>b.localeCompare(a));
const monthsSet=new Set();
const timeSet=new Set();
records.forEach(t=>{
(Array.isArray(t.purposes)?t.purposes:[]).forEach(p=>{
const start=Number(p.startMonth);
const end=Number(p.endMonth)||start;
if(!start)return;
let m=start;
for(let i=0;i<12;i++){
monthsSet.add(m);
if(m===end)break;
m=m%12+1;
}
});
const n=Number(t.travelMinutes);
if(t.travelMinutes!==""&&t.travelMinutes!=null&&Number.isFinite(n)&&n>=0){
timeSet.add(n);
}
});
const months=[...monthsSet].sort((a,b)=>a-b);
const maxTime=timeSet.size?Math.max(...timeSet):0;
const timeRanges=[];
for(let start=0;start<=maxTime;){
const end=start===0?15:start+14;
if([...timeSet].some(n=>n>=start&&n<=end)){
timeRanges.push({start,end});
}
start=start===0?16:start+15;
}
const selectedYears=new Set();
const selectedMonths=new Set();
const selectedTimes=new Set();
const selectedRatings=new Set();
const selectedAccess=new Set();
let searchText="";
let yearOpen=false;
let monthOpen=false;
let timeOpen=false;
let ratingOpen=false;
let accessOpen=false;
const bar=document.createElement("div");
bar.style.display="flex";
bar.style.flexWrap="wrap";
bar.style.alignItems="center";
bar.style.gap="6px";
bar.style.marginBottom="10px";
function button(label,fn){
const b=document.createElement("button");
b.type="button";
b.textContent=label;
b.style.padding="7px 10px";
b.style.border="1px solid #9bb5d5";
b.style.fontFamily="var(--dateFont)";
b.style.fontSize="var(--dateSize)";
b.style.borderRadius="7px";
b.style.background="var(--panelBg)";
b.style.color="var(--panelText)";
b.style.cursor="pointer";
b.onclick=fn;
return b;
}
const yearButton=button("年 ▽",()=>{
yearOpen=!yearOpen;
renderFilters();
});
const yearClear=button("✕",()=>{
selectedYears.clear();
yearOpen=false;
renderFilters();
renderRows();
});
const monthButton=button("時期 ▽",()=>{
monthOpen=!monthOpen;
renderFilters();
});
const monthClear=button("✕",()=>{
selectedMonths.clear();
monthOpen=false;
renderFilters();
renderRows();
});
const timeButton=button("時間 ▽",()=>{
timeOpen=!timeOpen;
renderFilters();
});
const timeClear=button("✕",()=>{
selectedTimes.clear();
timeOpen=false;
renderFilters();
renderRows();
});
const ratingButton=button("評価 ▽",()=>{
ratingOpen=!ratingOpen;
renderFilters();
});
const ratingClear=button("✕",()=>{
selectedRatings.clear();
ratingOpen=false;
renderFilters();
renderRows();
});
const accessButton=button("移動 ▽",()=>{
accessOpen=!accessOpen;
renderFilters();
});
const accessClear=button("✕",()=>{
selectedAccess.clear();
accessOpen=false;
renderFilters();
renderRows();
});
const search=document.createElement("input");
search.type="search";
search.placeholder="検索";
search.style.flex="1";
search.style.minWidth="120px";
search.style.fontFamily="var(--dateFont)";
search.style.fontSize="var(--dateSize)";
search.style.margin="0";
search.addEventListener("input",()=>{
searchText=search.value.trim().toLowerCase();
renderRows();
});
const count=document.createElement("span");
count.style.whiteSpace="nowrap";
count.style.fontWeight="bold";
bar.append(
yearButton,yearClear,
monthButton,monthClear,
timeButton,timeClear,
ratingButton,ratingClear,
accessButton,accessClear,
search,count
);
panel.appendChild(bar);
const optionBox=document.createElement("div");
optionBox.style.display="flex";
optionBox.style.flexDirection="column";
optionBox.style.gap="8px";
optionBox.style.marginBottom="12px";
panel.appendChild(optionBox);
const list=document.createElement("div");
list.className="trip-list";
panel.appendChild(list);
function optionGroup(values,selected,labelFn){
const box=document.createElement("div");
box.style.display="none";
box.style.flexWrap="wrap";
box.style.gap="6px";
values.forEach(value=>{
const b=button(labelFn(value),()=>{
if(selected.has(value))selected.delete(value);
else selected.add(value);
renderFilters();
renderRows();
});
if(selected.has(value)){
b.style.background="#315b8a";
b.style.color="white";
}
box.appendChild(b);
});
return box;
}
const yearOptions=optionGroup(
years,selectedYears,v=>v
);
const monthOptions=optionGroup(
months,selectedMonths,v=>String(v).padStart(2,"0")+"月"
);
const timeOptions=optionGroup(
timeRanges,selectedTimes,
v=>String(v.start).padStart(2,"0")+"~"+
String(v.end).padStart(2,"0")+"分"
);
const ratingOptions=optionGroup(
[1,2,3,4,5],selectedRatings,v=>String(v)
);
const accessOptions=optionGroup(
[1,2,3,4,5],selectedAccess,v=>String(v)
);
optionBox.append(
yearOptions,monthOptions,timeOptions,
ratingOptions,accessOptions
);
function renderFilters(){
[
[yearButton,yearOpen,"年",yearOptions],
[monthButton,monthOpen,"時期",monthOptions],
[timeButton,timeOpen,"時間",timeOptions],
[ratingButton,ratingOpen,"評価",ratingOptions],
[accessButton,accessOpen,"移動",accessOptions]
].forEach(([b,open,label,box])=>{
b.textContent=label+(open?" ▲":" ▽");
b.style.background=open?"#315b8a":"var(--panelBg)";
b.style.color=open?"white":"var(--panelText)";
box.style.display=open?"flex":"none";
});
[
[yearOptions,years,selectedYears,v=>v],
[monthOptions,months,selectedMonths,
v=>String(v).padStart(2,"0")+"月"],
[timeOptions,timeRanges,selectedTimes,
v=>String(v.start).padStart(2,"0")+"~"+
String(v.end).padStart(2,"0")+"分"],
[ratingOptions,[1,2,3,4,5],selectedRatings,v=>String(v)],
[accessOptions,[1,2,3,4,5],selectedAccess,v=>String(v)]
].forEach(([box,values,selected,labelFn])=>{
[...box.children].forEach((b,i)=>{
const value=values[i];
b.textContent=labelFn(value);
const isSelected=selected.has(value);
b.style.background=isSelected
?"#315b8a"
:"var(--panelBg)";
b.style.color=isSelected
?"white"
:"var(--panelText)";
});
});
}
function renderRows(){
list.innerHTML="";
const filtered=records.filter(t=>{
if(selectedYears.size&&!selectedYears.has(t.date.slice(0,4))){
return false;
}
const purposes=Array.isArray(t.purposes)?t.purposes:[];
const tripMonths=new Set();
purposes.forEach(p=>{
const start=Number(p.startMonth);
const end=Number(p.endMonth)||start;
if(!start)return;
let m=start;
for(let i=0;i<12;i++){
tripMonths.add(m);
if(m===end)break;
m=m%12+1;
}
});
if(selectedMonths.size&&
![...selectedMonths].some(m=>tripMonths.has(m))){
return false;
}
if(selectedTimes.size){
const n=Number(t.travelMinutes);
const matches=[...selectedTimes].some(range=>
Number.isFinite(n)&&
n>=range.start&&
n<=range.end
);
if(!matches)return false;
}
if(selectedRatings.size&&
!selectedRatings.has(Number(t.rating))){
return false;
}
if(selectedAccess.size&&
!selectedAccess.has(Number(t.access))){
return false;
}
if(searchText){
const purposeText=purposes.map(p=>p.purpose||"").join(" ");
const memoText=Array.isArray(t.memos)?t.memos.join(" "):"";
const text=[
t.title,t.prefecture,t.station,t.text,purposeText,memoText
].join(" ").toLowerCase();
if(!text.includes(searchText))return false;
}
return true;
}).sort((a,b)=>b.date.localeCompare(a.date));
count.textContent=filtered.length+"件";
if(!filtered.length){
const empty=document.createElement("p");
empty.textContent="該当する記録はありません。";
list.appendChild(empty);
return;
}
filtered.forEach(t=>{
const row=document.createElement("div");
row.className="trip";
row.style.display="grid";
row.style.gridTemplateColumns=
"minmax(110px,max-content) max-content minmax(0,1fr)";
row.style.gap="8px";
row.style.alignItems="center";
const date=document.createElement("div");
date.textContent=t.date;
date.style.fontFamily="var(--dateFont)";
date.style.fontSize="var(--dateSize)";
date.style.padding="5px 7px";
date.style.border="1px solid #7da7d9";
date.style.borderRadius="5px";
date.style.background="#e5efff";
date.style.color="#244b78";
date.style.whiteSpace="nowrap";
const time=document.createElement("div");
time.style.fontFamily="var(--dateFont)";
time.style.fontSize="var(--dateSize)";
time.textContent=t.travelMinutes!==""&&t.travelMinutes!=null
?String(t.travelMinutes).padStart(3,"0")+"分"
:"---分";
time.style.padding="5px 7px";
time.style.border="1px solid #7da7d9";
time.style.borderRadius="5px";
time.style.textAlign="center";
time.style.whiteSpace="nowrap";
const title=document.createElement("div");
title.className="trip-title";
title.style.minWidth="0";
title.style.whiteSpace="nowrap";
title.style.overflow="hidden";
title.style.textOverflow="ellipsis";
title.textContent=t.title||"";
row.append(date,time,title);
row.onclick=()=>editTrip(t.id);
list.appendChild(row);
});
}
renderFilters();
renderRows();
main.appendChild(panel);
}
/* =========================
追加・編集
========================= */
async function showAdd(){
setActive("add");
editingId=null;
editingPhotos=[];
editingPhotoColumns=DEFAULT_PHOTO_COLUMNS;
editingMemos=["","","","","","","",""];
const draft=await loadDraft();
if(draft){
const valid=[];
for(const p of draft.photos||[]){
if(!p.imageId)continue;
try{
await loadPhotoFromFolder(p.imageId);
valid.push({
id:p.id,
imageId:p.imageId,
comment:p.comment||""
});
}catch(e){
console.error("下書き写真を読み込めません:",e);
}
}
editingId=draft.editingId??null;
editingPhotos=valid;
editingPhotoColumns=normalizePhotoColumns(draft.photoColumns);
editingMemos=Array.isArray(draft.memos)
?[...draft.memos,"","","","","","",""].slice(0,8)
:["","","","","","","",""];
renderEditor(
draft.title||"",
draft.date||"",
draft.text||"",
true
);
}else{
renderEditor("","","",false);
}
const setValue=(id,value)=>{
const el=document.getElementById(id);
if(el)el.value=value??"";
};
setValue("tripPrefecture",draft?.prefecture);
setValue("tripStation",draft?.station);
setValue("tripTravelMinutes",draft?.travelMinutes);
setValue("tripRating",draft?.rating);
setValue("tripAccess",draft?.access);
const purposes=Array.isArray(draft?.purposes)
?draft.purposes:[];
[1,2,3].forEach(n=>{
const p=purposes[n-1]||{};
setValue("tripPurpose"+n,p.purpose);
setValue("tripStartMonth"+n,p.startMonth);
setValue("tripStartPeriod"+n,p.startPeriod);
setValue("tripEndMonth"+n,p.endMonth);
setValue("tripEndPeriod"+n,p.endPeriod);
});
[
"tripPrefecture",
"tripStation",
"tripTravelMinutes",
"tripRating",
"tripAccess",
...[1,2,3].flatMap(n=>[
"tripPurpose"+n,
"tripStartMonth"+n,
"tripStartPeriod"+n,
"tripEndMonth"+n,
"tripEndPeriod"+n
])
].forEach(id=>{
const el=document.getElementById(id);
if(el){
el.addEventListener("input",saveDraft);
el.addEventListener("change",saveDraft);
}
});
}
function openMemoEditor(index){
const current=editingMemos[index]||"";
document.querySelector(".memo-window")?.remove();
const win=document.createElement("div");
win.className="memo-window";
const header=document.createElement("div");
header.className="memo-window-header";
const title=document.createElement("div");
title.className="memo-window-title";
title.textContent="メモ"+(index+1);
const close=document.createElement("button");
close.type="button";
close.className="memo-window-close";
close.textContent="×";
header.append(title,close);
const body=document.createElement("div");
body.className="memo-window-body";
const textarea=document.createElement("textarea");
textarea.value=current;
textarea.placeholder="メモ"+(index+1);
textarea.oninput=()=>{
editingMemos[index]=textarea.value;
const button=document.querySelector(
'.memo-editor-button[data-memo-index="'+index+'"]'
);
if(button){
button.textContent=
textarea.value.trim()
? textarea.value.split("\n")[0]
: "メモ"+(index+1);
}
saveDraft();
};
body.appendChild(textarea);
win.append(header,body);
document.body.appendChild(win);
close.onclick=()=>{
editingMemos[index]=textarea.value;
saveDraft();
win.remove();
};
/* =========================
ヘッダーをドラッグした時だけ移動
========================= */
let dragging=false;
let offsetX=0;
let offsetY=0;
const move=e=>{
if(!dragging)return;
win.style.left=(e.clientX-offsetX)+"px";
win.style.top=(e.clientY-offsetY)+"px";
};
const stop=()=>{
if(!dragging)return;
dragging=false;
document.removeEventListener("mousemove",move);
document.removeEventListener("mouseup",stop);
};
header.addEventListener("mousedown",e=>{
/* ×ボタンを押した場合はドラッグ開始しない */
if(e.target===close)return;
const rect=win.getBoundingClientRect();
offsetX=e.clientX-rect.left;
offsetY=e.clientY-rect.top;
win.style.transform="none";
dragging=true;
document.addEventListener("mousemove",move);
document.addEventListener("mouseup",stop);
e.preventDefault();
});
textarea.focus();
}
function renderEditor(title,date,text,fromDraft){
setPhotoColumns(editingPhotoColumns);
const editingTrip = editingId!==null
? trips.find(t=>String(t.id)===String(editingId))
: null;
const lastEditedDate = editingTrip?.lastEdited
? new Date(editingTrip.lastEdited)
: null;
const lastEditedText = lastEditedDate &&
!isNaN(lastEditedDate.getTime())
? `${lastEditedDate.getFullYear()}/${String(lastEditedDate.getMonth()+1).padStart(2,"0")}/${String(lastEditedDate.getDate()).padStart(2,"0")}`
: "";
document.getElementById("main").innerHTML=`
<div class="panel">
${editingId!==null?`
<div class="editor-heading">
<span class="editor-heading-title">記録を編集</span>
${lastEditedText
? `<div style="font-size:14px;color:var(--panelText);margin-top:5px;">最終編集:${lastEditedText}</div>`
: ""}
</div>
`:""}
${fromDraft?'<div class="draft-status" style="display:block">前回の入力内容を下書きから復元</div>':""}
<input id="tripTitle" class="title-input" placeholder="題名"
value="${escapeAttr(title)}">
<div style="border:2px solid var(--border);border-radius:10px;padding:12px;margin-bottom:14px;background:var(--light);">
<div style="display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:10px;align-items:end;">
<label style="display:block;">
日付
<input id="tripDate" type="date" value="${escapeAttr(date)}" style="display:block;width:100%;margin:5px 0 0;">
</label>
<label style="display:block;">
評価
<select id="tripRating" style="display:block;width:100%;padding:10px;margin-top:5px;border:1px solid var(--border);border-radius:8px;background:var(--inputBg);color:var(--inputText);">
<option value="">未評価</option>
${[1,2,3,4,5].map(n=>`<option value="${n}">${n}</option>`).join("")}
</select>
</label>
<label style="display:block;">
アクセス
<select id="tripAccess" style="display:block;width:100%;padding:10px;margin-top:5px;border:1px solid var(--border);border-radius:8px;background:var(--inputBg);color:var(--inputText);">
<option value="">未選択</option>
${[1,2,3,4,5].map(n=>`<option value="${n}">${n}</option>`).join("")}
</select>
</label>
</div>
</div>
<div class="travel-info-row" style="display:grid;grid-template-columns:1fr 1.5fr 1fr;gap:10px;margin-bottom:12px;">
<label>
都道府県
<select id="tripPrefecture" style="width:100%;padding:10px;border:1px solid var(--border);border-radius:8px;background:var(--inputBg);color:var(--inputText);">
<option value="">選択してください</option>
${["北海道","青森県","岩手県","宮城県","秋田県","山形県","福島県","茨城県","栃木県","群馬県","埼玉県","千葉県","東京都","神奈川県","新潟県","富山県","石川県","福井県","山梨県","長野県","岐阜県","静岡県","愛知県","三重県","滋賀県","京都府","大阪府","兵庫県","奈良県","和歌山県","鳥取県","島根県","岡山県","広島県","山口県","徳島県","香川県","愛媛県","高知県","福岡県","佐賀県","長崎県","熊本県","大分県","宮崎県","鹿児島県","沖縄県"].map(p=>`<option value="${p}">${p}</option>`).join("")}
</select>
</label>
<label>
最寄駅
<input id="tripStation" type="text" placeholder="最寄駅" style="margin:0;">
</label>
<label>
移動時間目安(分)
<input id="tripTravelMinutes" type="number" min="0" placeholder="分" style="margin:0;">
</label>
</div>
${[1,2,3].map(n=>`
<div style="display:grid;grid-template-columns:minmax(160px,1.4fr) minmax(0,2fr);gap:10px;align-items:center;margin-bottom:12px;">
<input id="tripPurpose${n}" type="text" placeholder="目的${n}" style="margin:0;">
<div style="display:grid;grid-template-columns:minmax(0,1fr) 24px minmax(0,1fr);gap:5px;align-items:center;">
<div style="display:flex;gap:4px;min-width:0;">
<select id="tripStartMonth${n}" style="width:55%;min-width:0;padding:9px 2px;border:1px solid var(--border);border-radius:7px;background:var(--inputBg);color:var(--inputText);">
<option value="">月</option>
${Array.from({length:12},(_,i)=>`<option value="${String(i+1).padStart(2,"0")}">${String(i+1).padStart(2,"0")}月</option>`).join("")}
</select>
<select id="tripStartPeriod${n}" style="width:45%;min-width:0;padding:9px 2px;border:1px solid var(--border);border-radius:7px;background:var(--inputBg);color:var(--inputText);">
<option value="">旬</option>
<option value="上旬">上旬</option>
<option value="中旬">中旬</option>
<option value="下旬">下旬</option>
</select>
</div>
<span style="text-align:center;">~</span>
<div style="display:flex;gap:4px;min-width:0;">
<select id="tripEndMonth${n}" style="width:55%;min-width:0;padding:9px 2px;border:1px solid var(--border);border-radius:7px;background:var(--inputBg);color:var(--inputText);">
<option value="">月</option>
${Array.from({length:12},(_,i)=>`<option value="${String(i+1).padStart(2,"0")}">${String(i+1).padStart(2,"0")}月</option>`).join("")}
</select>
<select id="tripEndPeriod${n}" style="width:45%;min-width:0;padding:9px 2px;border:1px solid var(--border);border-radius:7px;background:var(--inputBg);color:var(--inputText);">
<option value="">旬</option>
<option value="上旬">上旬</option>
<option value="中旬">中旬</option>
<option value="下旬">下旬</option>
</select>
</div>
</div>
</div>
`).join("")}
<textarea id="tripText" placeholder="本文">${escapeHtml(text)}</textarea>
<h3>メモ</h3>
<div id="memoEditor" class="memo-editor">
<div class="memo-editor-row">
<button type="button" class="memo-editor-button" data-memo-index="0"></button>
<button type="button" class="memo-editor-button" data-memo-index="1"></button>
<button type="button" class="memo-editor-button" data-memo-index="2"></button>
<button type="button" class="memo-editor-button" data-memo-index="3"></button>
</div>
<div class="memo-editor-row">
<button type="button" class="memo-editor-button" data-memo-index="4"></button>
<button type="button" class="memo-editor-button" data-memo-index="5"></button>
<button type="button" class="memo-editor-button" data-memo-index="6"></button>
<button type="button" class="memo-editor-button" data-memo-index="7"></button>
</div>
</div>
<h3>写真</h3>
<div class="photo-count-editor">
<div class="photo-count-editor-title">
この記録の写真欄サイズ(1行あたりの列数)
</div>
<div id="editorPhotoCount" class="photo-count-editor-buttons"></div>
</div>
<div id="photoDrop" class="drop-zone">
写真をここへドラッグ&ドロップ<br>
またはクリックして写真を選択
<input id="photoInput" type="file" accept="image/*" multiple hidden>
</div>
<div id="editPhotos" class="edit-photo-grid"></div>
<div id="saveStatus" class="save-status"></div>
<button id="saveTripBtn" class="action edit-action save">保存</button>
<button id="deleteTripBtn" class="action edit-action delete" type="button">削除</button>
<button id="cancelTripBtn" class="action edit-action cancel">キャンセル</button>
</div>`;
document.querySelectorAll(".memo-editor-button").forEach(button=>{
const index=Number(button.dataset.memoIndex);
const value=String(editingMemos[index]||"").split("\n")[0];
button.textContent=
value.trim()
? value
: "メモ"+(index+1);
button.onclick=()=>{
openMemoEditor(index);
};
});
renderEditorPhotoCount();
setupPhotoDrop();
renderEditPhotos();
document.getElementById("saveTripBtn").onclick=saveTripOnce;
const deleteTripBtn=document.getElementById("deleteTripBtn");
if(deleteTripBtn){
deleteTripBtn.onclick=async()=>{
if(editingId===null)return;
const trip=trips.find(
x=>String(x.id)===String(editingId)
);
if(!trip){
alert("削除する記録が見つかりません。");
return;
}
if(!confirm(
"「"+trip.title+"」を削除しますか?\n\n"+
"記録本文・メモ・写真・写真コメントが削除されます。\n"+
"この操作は元に戻せません。"
))return;
deleteTripBtn.disabled=true;
const oldTrips=trips;
trips=trips.filter(
x=>String(x.id)!==String(editingId)
);
try{
const saved=await saveTrips();
if(!saved){
trips=oldTrips;
deleteTripBtn.disabled=false;
return;
}
clearDraft();
let failedPhotoDeletes=0;
for(const photo of trip.photos||[]){
if(!photo?.imageId)continue;
try{
await deletePhotoFromFolder(photo.imageId);
}catch(e){
failedPhotoDeletes++;
console.error("写真ファイル削除エラー:",e);
}
}
const deletedTripHadDate=!!trip.date;
editingId=null;
editingPhotos=[];
editingMemos=["","","","","","","",""];
editingPhotoColumns=DEFAULT_PHOTO_COLUMNS;
if(deletedTripHadDate){
showYearRecords();
}else{
showYears();
}
if(failedPhotoDeletes>0){
alert(
"記録は削除されましたが、"+
failedPhotoDeletes+"枚の写真ファイルを削除できませんでした。\n\n"+
"記録管理フォルダ内の写真を確認してください。"
);
}
}catch(e){
console.error("記録削除エラー:",e);
alert("削除処理中にエラーが発生しました。");
deleteTripBtn.disabled=false;
}
};
}
document.getElementById("cancelTripBtn").onclick=()=>{
clearDraft();
editingId=null;
editingPhotos=[];
editingPhotoColumns=DEFAULT_PHOTO_COLUMNS;
showYears();
};
const ti=document.getElementById("tripTitle");
const di=document.getElementById("tripDate");
const tx=document.getElementById("tripText");
ti.oninput=saveDraft;
di.oninput=()=>{
updateWeekday(di.value);
saveDraft();
};
tx.oninput=saveDraft;
updateWeekday(date);
}
/* =========================
列数設定
========================= */
function renderEditorPhotoCount(){
const box=document.getElementById("editorPhotoCount");
if(!box)return;
box.innerHTML="";
PHOTO_COUNT_OPTIONS.forEach(n=>{
const b=document.createElement("button");
b.type="button";
b.className="photo-count-editor-button";
b.textContent=n+"列";
if(normalizePhotoColumns(editingPhotoColumns)===n)
b.classList.add("selected");
b.onclick=()=>{
editingPhotoColumns=normalizePhotoColumns(n);
setPhotoColumns(editingPhotoColumns);
renderEditorPhotoCount();
renderEditPhotos();
saveDraft();
};
box.appendChild(b);
});
}
/* =========================
保存
========================= */
async function saveTripOnce(){
if(saveLock)return;
saveLock=true;
const button=document.getElementById("saveTripBtn");
if(button)button.disabled=true;
try{
const getValue=id=>
document.getElementById(id)?.value||"";
const title=getValue("tripTitle").trim();
const date=getValue("tripDate");
const text=getValue("tripText");
const rating=getValue("tripRating");
const access=getValue("tripAccess");
if(!title){
alert("題名を入力してください。");
return;
}
const prefecture=getValue("tripPrefecture");
const station=getValue("tripStation").trim();
const travelMinutes=getValue("tripTravelMinutes");
const purposes=[1,2,3].map(n=>({
purpose:getValue("tripPurpose"+n).trim(),
startMonth:getValue("tripStartMonth"+n),
startPeriod:getValue("tripStartPeriod"+n),
endMonth:getValue("tripEndMonth"+n),
endPeriod:getValue("tripEndPeriod"+n)
}));
const photos=editingPhotos.map(p=>({
id:p.id,
imageId:p.imageId,
comment:p.comment||""
}));
const photoColumns=normalizePhotoColumns(editingPhotoColumns);
const memos=Array.isArray(editingMemos)
? [...editingMemos,"","","","","","",""].slice(0,8)
: ["","","","","","","",""];
let targetTrip,newTrip=false,contentChanged=true;
if(editingId===null){
targetTrip={
id:Date.now()+"_"+Math.random().toString(36).slice(2),
title,
date,
text,
rating,
access,
prefecture,
station,
travelMinutes,
purposes,
memos,
photoColumns,
photos
};
trips.push(targetTrip);
newTrip=true;
}else{
targetTrip=trips.find(
x=>String(x.id)===String(editingId)
);
if(!targetTrip){
editingId=null;
targetTrip={
id:Date.now()+"_"+Math.random().toString(36).slice(2),
title,
date,
text,
rating,
access,
prefecture,
station,
travelMinutes,
purposes,
memos,
photoColumns,
photos
};
trips.push(targetTrip);
newTrip=true;
}else{
const sameValue=(a,b)=>
String(a??"")===String(b??"");
const normalizePurposes=items=>
[0,1,2].map(i=>{
const p=(items||[])[i]||{};
return {
purpose:String(p.purpose||""),
startMonth:String(p.startMonth||""),
startPeriod:String(p.startPeriod||""),
endMonth:String(p.endMonth||""),
endPeriod:String(p.endPeriod||"")
};
});
const normalizeMemos=items=>
[...(Array.isArray(items)?items:[]),
"","","","","","",""]
.slice(0,8)
.map(v=>String(v||""));
const normalizePhotos=items=>
(Array.isArray(items)?items:[]).map(p=>({
id:p.id,
imageId:p.imageId,
comment:p.comment||""
}));
contentChanged=
!sameValue(targetTrip.title,title)||
!sameValue(targetTrip.date,date)||
!sameValue(targetTrip.text,text)||
!sameValue(targetTrip.rating,rating)||
!sameValue(targetTrip.access,access)||
!sameValue(targetTrip.prefecture,prefecture)||
!sameValue(targetTrip.station,station)||
!sameValue(targetTrip.travelMinutes,travelMinutes)||
JSON.stringify(normalizePurposes(targetTrip.purposes))!==
JSON.stringify(normalizePurposes(purposes))||
JSON.stringify(normalizeMemos(targetTrip.memos))!==
JSON.stringify(normalizeMemos(memos))||
normalizePhotoColumns(targetTrip.photoColumns)!==photoColumns||
JSON.stringify(normalizePhotos(targetTrip.photos))!==
JSON.stringify(normalizePhotos(photos));
targetTrip.title=title;
targetTrip.date=date;
targetTrip.text=text;
targetTrip.rating=rating;
targetTrip.access=access;
targetTrip.prefecture=prefecture;
targetTrip.station=station;
targetTrip.travelMinutes=travelMinutes;
targetTrip.purposes=purposes;
targetTrip.memos=memos;
targetTrip.photoColumns=photoColumns;
targetTrip.photos=photos;
}
}
if(contentChanged){
targetTrip.lastEdited=new Date().toISOString();
}
if(!(await saveTrips())){
if(newTrip){
trips=trips.filter(x=>x.id!==targetTrip.id);
}
return;
}
await clearDraft();
editingId=targetTrip.id;
if(date){
showYearRecords();
}else{
showYears();
}
}catch(e){
console.error(e);
alert(
"保存中にエラーが発生しました。\n"+
"保存処理を完了できませんでした。"
);
}finally{
saveLock=false;
if(button)button.disabled=false;
}
}
/* =========================
写真追加
★枚数制限を撤廃
========================= */
function setupPhotoDrop(){
const zone=document.getElementById("photoDrop");
const input=document.getElementById("photoInput");
if(!zone||!input)return;
zone.onclick=e=>{
if(e.target!==input)input.click();
};
input.onchange=e=>{
addPhotoFiles([...e.target.files]);
input.value="";
};
zone.ondragover=e=>{
e.preventDefault();
zone.classList.add("dragover");
};
zone.ondragleave=()=>zone.classList.remove("dragover");
zone.ondrop=e=>{
e.preventDefault();
zone.classList.remove("dragover");
addPhotoFiles([...e.dataTransfer.files].filter(f=>f.type.startsWith("image/")));
};
}
async function addPhotoFiles(files){
if(!files.length)return;
const status=document.getElementById("saveStatus");
if(status){
status.style.display="block";
status.textContent="写真を縮小・保存しています…";
}
try{
let added=0;
for(const file of files){
const blob=await compressImageBlob(file,1600,1600,.75);
const imageId="img_"+Date.now()+"_"+Math.random().toString(36).slice(2);
await savePhotoToFolder(imageId,blob);
editingPhotos.push({
id:"photo_"+Date.now()+"_"+Math.random().toString(36).slice(2),
imageId,
comment:""
});
added++;
await renderEditPhotos();
saveDraft();
}
if(status)status.textContent=added+"枚の写真を追加しました。";
}catch(e){
console.error(e);
alert("写真を追加できませんでした。\nブラウザの画像保存領域を確認してください。");
}finally{
setTimeout(()=>{
if(status)status.style.display="none";
},1500);
}
}
/* =========================
画像圧縮
========================= */
function compressImageBlob(file,maxWidth,maxHeight,quality){
return new Promise((resolve,reject)=>{
const reader=new FileReader();
reader.onerror=()=>reject(new Error("画像読み込み失敗"));
reader.onload=()=>{
const img=new Image();
img.onerror=()=>reject(new Error("画像読み込み失敗"));
img.onload=()=>{
let w=img.width,h=img.height;
const ratio=Math.min(maxWidth/w,maxHeight/h,1);
w=Math.max(1,Math.round(w*ratio));
h=Math.max(1,Math.round(h*ratio));
const canvas=document.createElement("canvas");
canvas.width=w;canvas.height=h;
const ctx=canvas.getContext("2d");
ctx.fillStyle="#fff";
ctx.fillRect(0,0,w,h);
ctx.drawImage(img,0,0,w,h);
canvas.toBlob(blob=>{
if(!blob)return reject(new Error("画像圧縮失敗"));
resolve(blob);
},"image/jpeg",quality);
};
img.src=reader.result;
};
reader.readAsDataURL(file);
});
}
/* =========================
編集写真
========================= */
async function renderEditPhotos(){
setPhotoColumns(editingPhotoColumns);
const box=document.getElementById("editPhotos");
if(!box)return;
box.innerHTML="";
for(let index=0;index<editingPhotos.length;index++){
const photo=editingPhotos[index];
const card=document.createElement("div");
card.className="edit-photo";
card.draggable=true;
card.dataset.index=index;
const frame=document.createElement("div");
frame.className="edit-photo-frame";
const img=document.createElement("img");
try{
const blob=await loadPhotoFromFolder(photo.imageId);
if(blob)img.src=URL.createObjectURL(blob);
}catch(e){
console.error(e);
}
frame.appendChild(img);
card.appendChild(frame);
const cb=document.createElement("div");
cb.className="edit-comment-box";
const comment=document.createElement("textarea");
comment.placeholder="コメント";
comment.value=photo.comment||"";
comment.oninput=()=>{
photo.comment=comment.value;
saveDraft();
};
cb.appendChild(comment);
card.appendChild(cb);
const del=document.createElement("button");
del.className="action edit-action delete";
del.textContent="削除";
del.onclick=async()=>{
if(!photo.imageId)return;
try{
await deletePhotoFromFolder(photo.imageId);
editingPhotos.splice(index,1);
saveDraft();
await renderEditPhotos();
}catch(e){
console.error("写真削除エラー:",e);
alert(
"写真ファイルを削除できなかったため、\n"+
"写真の削除処理を中止しました。"
);
}
};
card.appendChild(del);
setupEditDrag(card);
box.appendChild(card);
}
}
function setupEditDrag(card){
card.ondragstart=e=>{
card.classList.add("dragging");
e.dataTransfer.setData("text/plain",card.dataset.index);
};
card.ondragend=()=>{
card.classList.remove("dragging");
document.querySelectorAll(".drag-over").forEach(x=>x.classList.remove("drag-over"));
};
card.ondragover=e=>{
e.preventDefault();
if(!card.classList.contains("dragging"))card.classList.add("drag-over");
};
card.ondragleave=()=>card.classList.remove("drag-over");
card.ondrop=e=>{
e.preventDefault();
const from=Number(e.dataTransfer.getData("text/plain"));
const to=Number(card.dataset.index);
card.classList.remove("drag-over");
movePhoto(editingPhotos,from,to);
saveDraft();
renderEditPhotos();
};
}
function movePhoto(array,from,to){
if(from<0||to<0||from>=array.length||to>=array.length||from===to)return;
const item=array.splice(from,1)[0];
array.splice(to,0,item);
}
/* =========================
詳細表示
========================= */
async function showDetail(id){
const trip=trips.find(x=>x.id===id);
if(!trip)return;
setPhotoColumns(trip.photoColumns);
setActive("years");
const main=document.getElementById("main");
main.innerHTML="";
const box=document.createElement("div");
box.className="detail";
const title=document.createElement("div");
title.className="detail-title";
title.textContent=trip.title;
const date=document.createElement("div");
date.className="detail-date";
date.textContent=dateText(trip.date);
box.append(title,date);
if(trip.text){
const text=document.createElement("div");
text.className="detail-text";
text.textContent=trip.text;
box.appendChild(text);
}
/* =========================
メモ表示
========================= */
const memoTitle=document.createElement("h3");
memoTitle.textContent="メモ";
box.appendChild(memoTitle);
const memoGrid=document.createElement("div");
memoGrid.className="memo-view-grid";
const memos=Array.isArray(trip.memos)
? trip.memos
: [];
for(let i=0;i<8;i++){
const item=document.createElement("div");
item.className="memo-view-item";
const value=String(memos[i]||"").split("\n")[0];
if(!value){
item.textContent="未入力";
}else{
item.textContent=value;
requestAnimationFrame(()=>{
if(item.scrollWidth>item.clientWidth){
let text=value;
while(text.length>0){
item.textContent=text+"…";
if(item.scrollWidth<=item.clientWidth){
break;
}
text=text.slice(0,-1);
}
}
});
}
memoGrid.appendChild(item);
}
box.appendChild(memoGrid);
const grid=document.createElement("div");
grid.className="photo-grid";
box.appendChild(grid);
await renderViewPhotos(trip,grid);
const edit=document.createElement("button");
edit.className="action save";
edit.textContent="編集";
edit.onclick=()=>editTrip(trip.id);
const del=document.createElement("button");
del.className="action delete";
del.textContent="削除";
del.onclick=async()=>{
if(!confirm("この記録を削除しますか?"))return;
const old=trips;
trips=trips.filter(x=>x.id!==trip.id);
if(await saveTrips()){
for(const p of trip.photos||[]){
try{
await deletePhotoFromFolder(p.imageId);
}catch(e){
console.error("写真削除エラー:",e);
}
}
showYears();
}else{
trips=old;
}
};
const back=document.createElement("button");
back.className="action cancel";
back.textContent="戻る";
back.onclick=()=>showYear(trip.date.slice(0,4));
box.append(edit,del,back);
main.appendChild(box);
}
async function renderViewPhotos(trip,grid){
setPhotoColumns(trip.photoColumns);
grid.innerHTML="";
for(let index=0;index<(trip.photos||[]).length;index++){
const photo=trip.photos[index];
const item=document.createElement("div");
item.className="photo-item";
item.draggable=true;
item.dataset.index=index;
const frame=document.createElement("div");
frame.className="photo-frame";
const img=document.createElement("img");
try{
const blob=await loadPhotoFromFolder(photo.imageId);
if(blob){
const url=URL.createObjectURL(blob);
img.src=url;
img.onclick=()=>openPhoto(url);
}
}catch(e){console.error(e)}
frame.appendChild(img);
item.appendChild(frame);
if(photo.comment){
const cb=document.createElement("div");
cb.className="photo-comment-box";
const c=document.createElement("div");
c.className="photo-comment";
c.textContent=photo.comment;
cb.appendChild(c);
item.appendChild(cb);
}
item.ondragstart=e=>{
item.classList.add("dragging");
e.dataTransfer.setData("text/plain",index);
};
item.ondragend=()=>item.classList.remove("dragging");
item.ondragover=e=>{
e.preventDefault();
if(!item.classList.contains("dragging"))item.classList.add("drag-over");
};
item.ondragleave=()=>item.classList.remove("drag-over");
item.ondrop=async e=>{
e.preventDefault();
const from=Number(e.dataTransfer.getData("text/plain"));
item.classList.remove("drag-over");
movePhoto(trip.photos,from,index);
if(await saveTrips())renderViewPhotos(trip,grid);
};
grid.appendChild(item);
}
}
/* =========================
編集
========================= */
async function editTrip(id) {
const trip = trips.find(x => String(x.id) === String(id));
if (!trip) return;
// 編集画面を開く前に、該当記録の下書きを読み込む
let draft = null;
try {
draft = await loadDraft();
} catch (e) {
console.error("下書き読み込みエラー:", e);
}
// 別の記録の下書きは混ぜない
const hasDraft =
draft &&
String(draft.editingId) === String(trip.id);
const data = hasDraft ? draft : trip;
setActive("add");
editingId = trip.id;
editingPhotoColumns = normalizePhotoColumns(
data.photoColumns ?? trip.photoColumns
);
editingMemos = Array.isArray(data.memos)
? [...data.memos, "", "", "", "", "", "", ""].slice(0, 8)
: Array.isArray(trip.memos)
? [...trip.memos, "", "", "", "", "", "", ""].slice(0, 8)
: ["", "", "", "", "", "", "", ""];
// 下書きの写真が存在する場合は、その内容を復元する
if (hasDraft && Array.isArray(draft.photos)) {
const valid = [];
for (const p of draft.photos) {
if (!p.imageId) continue;
try {
await loadPhotoFromFolder(p.imageId);
valid.push({
id: p.id,
imageId: p.imageId,
comment: p.comment || ""
});
} catch (e) {
console.error("下書き写真の読み込みエラー:", e);
}
}
editingPhotos = valid;
} else {
editingPhotos = JSON.parse(
JSON.stringify(trip.photos || [])
);
}
renderEditor(
data.title ?? "",
data.date ?? "",
data.text ?? "",
!!hasDraft
);
const setValue = (id, value) => {
const el = document.getElementById(id);
if (el) el.value = value ?? "";
};
setValue("tripPrefecture", data.prefecture ?? trip.prefecture);
setValue("tripStation", data.station ?? trip.station);
setValue("tripTravelMinutes", data.travelMinutes ?? trip.travelMinutes);
setValue("tripRating", data.rating ?? trip.rating);
setValue("tripAccess", data.access ?? trip.access);
const purposes = Array.isArray(data.purposes)
? data.purposes
: Array.isArray(trip.purposes)
? trip.purposes
: [];
[1, 2, 3].forEach(n => {
const p = purposes[n - 1] || {};
setValue("tripPurpose" + n, p.purpose);
setValue("tripStartMonth" + n, p.startMonth);
setValue("tripStartPeriod" + n, p.startPeriod);
setValue("tripEndMonth" + n, p.endMonth);
setValue("tripEndPeriod" + n, p.endPeriod);
});
[
"tripPrefecture",
"tripStation",
"tripTravelMinutes",
"tripRating",
"tripAccess",
...[1, 2, 3].flatMap(n => [
"tripPurpose" + n,
"tripStartMonth" + n,
"tripStartPeriod" + n,
"tripEndMonth" + n,
"tripEndPeriod" + n
])
].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener("input", saveDraft);
el.addEventListener("change", saveDraft);
}
});
// 重要:
// ここで saveDraftBeforeNavigation() を呼ばない。
// 編集画面を開いただけで、保存済みの下書きを消さないため。
}
/* =========================
設定
========================= */
function showSettings(){
saveDraftBeforeNavigation();
setActive("settings");
document.getElementById("main").innerHTML=`
<div class="settings-section">
<h2>タイトル変更</h2>
<input id="newAppTitle" value="${escapeAttr(settings.title)}">
<button id="titleChangeBtn" class="action save">タイトル変更</button>
</div>
<div class="settings-section">
<h2>イメージカラー</h2>
<p>色をドラッグして並べ替えできます。</p>
<div id="colors" class="color-list"></div>
</div>
<div class="settings-section">
<h2>ダークモード</h2>
<div id="darkMode" class="dark-mode-row"></div>
</div>
<div class="settings-section">
<h2>文字設定</h2>
<div id="sizes"></div>
<button id="resetTextSettingsBtn" class="action cancel">
文字設定リセット
</button>
</div>`;
document.getElementById("titleChangeBtn").onclick=async()=>{
const v=document.getElementById("newAppTitle").value.trim();
if(!v)return;
const old=settings.title;
settings.title=v;
if(!(await saveSettings())){
settings.title=old;
return;
}
applySettings();
showSettings();
};
renderColors();
renderDarkMode();
renderSizes();
document.getElementById("resetTextSettingsBtn").onclick=
resetTextSettings;
}
/* =========================
アプリ説明
========================= */
function showAppDescription(){
saveDraftBeforeNavigation();
setActive("appDescription");
document.getElementById("main").innerHTML=`
<div class="panel app-description">
<h2>アプリの使い方</h2>
<p>このアプリは、旅行の候補や実際に訪れた場所の記録を管理するアプリです。</p>
<h3>一覧と年別</h3>
<p><strong>年別:</strong>編集画面で日付を入力して保存すると、訪れた記録として年別に登録されます。<br>
日付のある記録は年別、日付のない候補は一覧に表示されます。年別は日付の新しい順に表示されます。</p>
<p><strong>一覧:</strong>日付を入力せずに保存すると、これから行きたい候補として一覧に登録されます。</p>
<h3>目的・月・旬</h3>
<p>目的は目的1・目的2・目的3の3つまで登録できます。それぞれに開始月・開始旬・終了月・終了旬を設定できます。<br>
旬は上旬・中旬・下旬を表します。例えば「4月上旬~5月中旬」のように、目的ごとに行きたい時期を指定できます。<br>
時期の絞り込みは目的1~3すべてが対象で、設定した期間に該当する記録を探せます。</p>
<h3>移動時間・評価・アクセス</h3>
<p>移動時間は目的地までの移動にかかる時間を分単位で登録します。評価は1~5で記録の評価を付けられます。<br>
アクセスも1~5で、数字が大きいほど行きやすく、5が最もアクセスしやすいことを表します。<br>
年別では年・時期・時間・評価・移動・検索を組み合わせて絞り込めます。</p>
<h3>編集・メモ・写真</h3>
<p>年別や一覧の記録を編集して、情報を追加・変更できます。<br>
本文、メモ、写真や写真コメントも活用して、訪問前の計画から訪問後の記録までまとめて管理できます。</p>
<h3>最終編集</h3>
<p>編集画面では、その記録を最後に保存した日付を「最終編集:YYYY/MM/DD」の形式で確認できます。<br>
時刻は表示ず保存するたびに日付が更新されます。閲覧のみでは更新されず入力のいずれかを変更する必要があります。<br>
過去の記録など、最終編集日の情報が保存されていない場合は、最初に保存するまで表示されません。</p>
<h3>文字設定</h3>
<p>「設定」の文字設定で表示する文字の種類や大きさを変更できます。</p>
</div>
`;
const style=document.createElement("style");
style.id="appDescriptionStyle";
style.textContent=`
.app-description{
font-family:var(--settingFont);
font-size:var(--settingSize);
line-height:1.6;
}
.app-description h2{
margin:0 0 10px;
color:var(--main);
font-family:var(--settingFont);
font-size:var(--settingSize);
}
.app-description h3{
margin:12px 0 3px;
color:var(--main);
font-family:var(--settingFont);
font-size:var(--settingSize);
}
.app-description p{
margin:3px 0 7px;
font-family:var(--settingFont);
font-size:var(--settingSize);
line-height:1.6;
}
`;
if(
document.body.classList.contains("image-white") &&
!document.body.classList.contains("dark-mode")
){
style.textContent += `
.app-description h2,
.app-description h3{
color:#000 !important;
}
`;
}
document.getElementById("appDescriptionStyle")?.remove();
document.head.appendChild(style);
}
/* =========================
イメージカラー
========================= */
function renderColors(){
const box=document.getElementById("colors");
if(!box)return;
box.innerHTML="";
settings.colors.forEach((color,index)=>{
const b=document.createElement("button");
b.className="color-choice";
if(color.toLowerCase()==="#ffffff"){
b.classList.add("white-color");
}
b.style.background=color;
b.draggable=true;
if(settings.colorIndex===index)b.classList.add("selected");
b.onclick=()=>{
settings.colorIndex=index;
saveSettings();
applySettings();
renderColors();
};
b.ondragstart=e=>e.dataTransfer.setData("text/plain",index);
b.ondragover=e=>{
e.preventDefault();
b.classList.add("drag-over");
};
b.ondragleave=()=>b.classList.remove("drag-over");
b.ondrop=e=>{
e.preventDefault();
const from=Number(e.dataTransfer.getData("text/plain"));
const to=index;
b.classList.remove("drag-over");
if(from===to)return;
const selected=settings.colors[settings.colorIndex];
const moved=settings.colors.splice(from,1)[0];
settings.colors.splice(to,0,moved);
settings.colorIndex=settings.colors.indexOf(selected);
saveSettings();
applySettings();
renderColors();
};
box.appendChild(b);
});
}
/* =========================
ダークモード
========================= */
function renderDarkMode(){
const box=document.getElementById("darkMode");
if(!box)return;
box.innerHTML="";
[
[false,"OFF"],
[true,"ON"]
].forEach(([value,label])=>{
const b=document.createElement("button");
b.className="dark-mode-button";
b.textContent=label;
if(settings.darkMode===value)
b.classList.add("selected");
b.onclick=()=>{
const old=settings.darkMode;
settings.darkMode=value;
if(!saveSettings()){
settings.darkMode=old;
return;
}
applySettings();
renderDarkMode();
};
box.appendChild(b);
});
}
/* =========================
文字設定
========================= */
const SIZE_TARGETS=[
["見出し","titleSize","titleFont"],
["年別・一覧","dateSize","dateFont"],
["編集","editSize","editFont"],
["本文","bodySize","bodyFont"],
["メモ","commentSize","commentFont"],
["設定","settingSize","settingFont"]
];
function renderSizes(){
const box=document.getElementById("sizes");
if(!box)return;
box.innerHTML="";
const descriptions={
"見出し":"サイドバーとアプリ上部タイトル",
"年別・一覧":"年別・一覧画面の文字",
"編集":"編集画面の文字",
"本文":"編集画面の本文のみ",
"メモ":"編集画面のメモ・写真コメントのみ",
"設定":"設定・背景設定・説明書画面の文字"
};
SIZE_TARGETS.forEach(([label,sizeKey,fontKey])=>{
const row=document.createElement("div");
row.className="size-setting";
const inner=document.createElement("div");
inner.className="size-setting-row";
/* 項目名 */
const name=document.createElement("div");
name.className="size-setting-name";
name.textContent=label;
inner.appendChild(name);
/* =========================
サイズ 1〜5
========================= */
const buttons=document.createElement("div");
buttons.className="size-buttons";
[1,2,3,4,5].forEach(n=>{
const b=document.createElement("button");
b.className="size-button";
b.textContent=n;
if(settings[sizeKey]===n)
b.classList.add("selected");
b.onclick=()=>{
const old=settings[sizeKey];
settings[sizeKey]=n;
if(!saveSettings()){
settings[sizeKey]=old;
return;
}
applySettings();
renderSizes();
};
buttons.appendChild(b);
});
inner.appendChild(buttons);
/* =========================
説明文
========================= */
const description=document.createElement("span");
description.textContent=
descriptions[label]||"";
description.style.fontWeight="normal";
description.style.color="#777";
description.style.fontSize="var(--settingSize)";
description.style.marginLeft="8px";
description.style.whiteSpace="nowrap";
inner.appendChild(description);
/* =========================
フォント選択
========================= */
const fontBox=document.createElement("div");
fontBox.className="font-select";
FONTS.forEach((font,index)=>{
const b=document.createElement("button");
b.className="font-choice";
b.style.fontFamily=font[1];
if(settings[fontKey]===font[0])
b.classList.add("selected");
/*
フォント名は表示せず、
A あ
B あ
C あ
の形式で表示
*/
const patternName=
String.fromCharCode(65+index);
b.innerHTML=
`<span class="font-choice-name">${patternName} </span>`+
`<span class="font-choice-sample" style="font-family:${font[1]}">あ</span>`;
b.onclick=()=>{
const old=settings[fontKey];
settings[fontKey]=font[0];
if(!saveSettings()){
settings[fontKey]=old;
return;
}
applySettings();
renderSizes();
};
fontBox.appendChild(b);
});
inner.appendChild(fontBox);
row.appendChild(inner);
box.appendChild(row);
});
}
/* =========================
文字設定リセット
========================= */
async function resetTextSettings(){
const ok=confirm(
"文字設定をリセットします。\n\n"+
"文字サイズをすべて「2」に戻し、"+
"フォントをすべて「游ゴシック」にします。\n\n"+
"よろしいですか?"
);
if(!ok)return;
const oldValues={
titleSize:settings.titleSize,
dateSize:settings.dateSize,
editSize:settings.editSize,
bodySize:settings.bodySize,
commentSize:settings.commentSize,
listSize:settings.listSize,
settingSize:settings.settingSize,
titleFont:settings.titleFont,
dateFont:settings.dateFont,
editFont:settings.editFont,
bodyFont:settings.bodyFont,
commentFont:settings.commentFont,
listFont:settings.listFont,
settingFont:settings.settingFont
};
settings.titleSize=2;
settings.dateSize=2;
settings.editSize=2;
settings.bodySize=2;
settings.commentSize=2;
settings.listSize=2;
settings.settingSize=2;
settings.titleFont="游ゴシック";
settings.dateFont="游ゴシック";
settings.editFont="游ゴシック";
settings.bodyFont="游ゴシック";
settings.commentFont="游ゴシック";
settings.listFont="游ゴシック";
settings.settingFont="游ゴシック";
if(!(await saveSettings())){
Object.assign(settings,oldValues);
return;
}
applySettings();
renderSizes();
}
/* =========================
白枠透過
========================= */
function renderTransparency(){
const box=document.getElementById("transparency");
if(!box)return;
box.innerHTML="";
[1,2,3,4,5].forEach(n=>{
const b=document.createElement("button");
b.className="transparency-button";
b.textContent=n;
if(settings.panelTransparency===n)
b.classList.add("selected");
b.onclick=()=>{
const old=settings.panelTransparency;
settings.panelTransparency=n;
if(!saveSettings()){
settings.panelTransparency=old;
return;
}
applySettings();
renderTransparency();
};
box.appendChild(b);
});
}
function renderTransparency(){
const box=document.getElementById("transparency");
if(!box)return;
box.innerHTML="";
[1,2,3,4,5].forEach(n=>{
const b=document.createElement("button");
b.className="transparency-button";
b.textContent=n;
if(settings.panelTransparency===n)b.classList.add("selected");
b.onclick=()=>{
const old=settings.panelTransparency;
settings.panelTransparency=n;
if(!saveSettings()){settings.panelTransparency=old;return}
applySettings();
renderTransparency();
};
box.appendChild(b);
});
}
/* =========================
背景画像・背景カラー
========================= */
async function showBackground(){
saveDraftBeforeNavigation();
setActive("background");
let preview="";
if(backgroundObjectUrl)
preview=`<img class="background-preview" src="${backgroundObjectUrl}">`;
document.getElementById("main").innerHTML=`
<div class="settings-section">
<h2>背景画像</h2>
<p>背景画像を設定すると、記録画面の後ろに表示されます。</p>
<div id="backgroundDrop" class="drop-zone">
背景画像をここへドラッグ&ドロップ<br>
またはクリックして選択
<input id="backgroundInput" type="file" accept="image/*" hidden>
${preview}
</div>
<button id="clearBackground" class="action delete">
背景画像を削除
</button>
</div>
<div class="settings-section">
<h2>背景画像使用時の白枠透過</h2>
<div id="transparency" class="transparency-row"></div>
</div>
<div class="settings-section">
<h2>背景カラー</h2>
<p>背景画像を設定していないときに適用されます。色をドラッグして並べ替えできます。</p>
<div id="backgroundColors" class="color-list"></div>
</div>`;
renderBackgroundColors();
renderTransparency();
const zone=document.getElementById("backgroundDrop");
const input=document.getElementById("backgroundInput");
zone.onclick=e=>{
if(e.target.tagName!=="IMG")input.click();
};
input.onchange=e=>{
if(e.target.files[0])setBackground(e.target.files[0]);
};
zone.ondragover=e=>{
e.preventDefault();
zone.classList.add("dragover");
};
zone.ondragleave=()=>zone.classList.remove("dragover");
zone.ondrop=e=>{
e.preventDefault();
zone.classList.remove("dragover");
const file=[...e.dataTransfer.files].find(f=>f.type.startsWith("image/"));
if(file)setBackground(file);
};
document.getElementById("clearBackground").onclick=clearBackground;
}
function renderBackgroundColors(){
const box=document.getElementById("backgroundColors");
if(!box)return;
box.innerHTML="";
settings.backgroundColors.forEach((color,index)=>{
const b=document.createElement("button");
b.className="color-choice";
if(color.toLowerCase()==="#ffffff"){
b.classList.add("white-color");
}
b.style.background=color;
b.draggable=true;
if(settings.backgroundColorIndex===index)
b.classList.add("selected");
b.onclick=()=>{
settings.backgroundColorIndex=index;
if(!saveSettings())return;
applyBackground();
renderBackgroundColors();
};
b.ondragstart=e=>{
e.dataTransfer.setData("text/plain",index);
};
b.ondragover=e=>{
e.preventDefault();
b.classList.add("drag-over");
};
b.ondragleave=()=>{
b.classList.remove("drag-over");
};
b.ondrop=e=>{
e.preventDefault();
const from=Number(e.dataTransfer.getData("text/plain"));
const to=index;
b.classList.remove("drag-over");
if(from===to)return;
const selected=settings.backgroundColors[
settings.backgroundColorIndex
];
const moved=settings.backgroundColors.splice(from,1)[0];
settings.backgroundColors.splice(to,0,moved);
settings.backgroundColorIndex=
settings.backgroundColors.indexOf(selected);
saveSettings();
applyBackground();
renderBackgroundColors();
};
box.appendChild(b);
});
}
async function setBackground(file){
try{
const blob=await compressImageBlob(file,1800,1200,.72);
const newId=
"background_"+Date.now()+"_"+
Math.random().toString(36).slice(2);
const folder=await recordFolderHandle.getDirectoryHandle(
BACKGROUND_DIR,
{create:true}
);
const fileHandle=await folder.getFileHandle(
newId+".jpg",
{create:true}
);
const writable=await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
const oldId=backgroundImageId;
backgroundImageId=newId;
settings.backgroundImageId=newId;
if(!(await saveSettings())){
backgroundImageId=oldId;
settings.backgroundImageId=oldId;
return;
}
await applyBackground();
if(oldId){
try{
const oldFileHandle=await folder.getFileHandle(
oldId+".jpg"
);
await oldFileHandle.remove();
}catch(e){
console.error(e);
}
}
showBackground();
}catch(e){
console.error(e);
alert("背景画像を保存できませんでした。");
}
}
/* =========================
背景画像削除
========================= */
async function clearBackground(){
const old=backgroundImageId;
try{
backgroundImageId=null;
settings.backgroundImageId=null;
if(!(await saveSettings())){
backgroundImageId=old;
settings.backgroundImageId=old;
return;
}
await applyBackground();
if(old){
try{
const folder=await recordFolderHandle.getDirectoryHandle(
BACKGROUND_DIR,
{create:false}
);
const fileHandle=await folder.getFileHandle(
old+".jpg"
);
await fileHandle.remove();
}catch(e){
console.error("背景画像ファイルの削除に失敗:",e);
}
}
showBackground();
}catch(e){
console.error(e);
backgroundImageId=old;
settings.backgroundImageId=old;
await applyBackground();
alert("背景画像を削除できませんでした。");
}
}
/* =========================
背景
========================= */
async function applyBackground(){
if(backgroundObjectUrl){
URL.revokeObjectURL(backgroundObjectUrl);
backgroundObjectUrl=null;
}
/*
背景画像がある場合は
記録管理フォルダから読み込む。
*/
if(backgroundImageId){
try{
const folder=await recordFolderHandle.getDirectoryHandle(
"background",
{create:false}
);
const fileHandle=await folder.getFileHandle(
backgroundImageId+".jpg"
);
const file=await fileHandle.getFile();
backgroundObjectUrl=URL.createObjectURL(file);
document.body.style.backgroundImage=
`url("${backgroundObjectUrl}")`;
return;
}catch(e){
console.error(e);
}
}
/*
背景画像がない場合だけ背景カラーを適用。
*/
document.body.style.backgroundImage="";
const backgroundColor=
settings.backgroundColors[
settings.backgroundColorIndex
]||"#ffffff";
document.body.style.backgroundColor=backgroundColor;
}
/* =========================
設定適用
========================= */
function applySettings(){
const root=document.documentElement;
const mainColor=settings.colors[settings.colorIndex]||DEFAULT_COLORS[0];
document.body.classList.toggle(
"image-white",
String(mainColor).toLowerCase()==="#ffffff"
);
document.body.classList.toggle(
"image-black",
String(mainColor).toLowerCase()==="#333333"
);
root.style.setProperty("--main",mainColor);
root.style.setProperty("--titleFont",getFontFamily(settings.titleFont));
root.style.setProperty("--dateFont",getFontFamily(settings.dateFont));
root.style.setProperty("--editFont",getFontFamily(settings.editFont));
root.style.setProperty("--bodyFont",getFontFamily(settings.bodyFont));
root.style.setProperty("--commentFont",getFontFamily(settings.commentFont));
root.style.setProperty("--listFont",getFontFamily(settings.listFont));
root.style.setProperty("--settingFont",getFontFamily(settings.settingFont));
const sizes={
titleSize:[15,18,21,25,30],
appTitleSize:[25,30,36,42,50],
dateSize:[14,16,18,20,24],
editSize:[12,16,20,24,28],
bodySize:[12,14,16,18,20],
commentSize:[12,14,16,18,20],
listSize:[14,16,18,20,24],
settingSize:[12,14,16,18,22]
};
const titleLevel=Math.max(
0,
Math.min(4,Number(settings.titleSize||2)-1)
);
root.style.setProperty(
"--titleSize",
sizes.titleSize[titleLevel]+"px"
);
root.style.setProperty(
"--appTitleSize",
sizes.appTitleSize[titleLevel]+"px"
);
[
"dateSize",
"editSize",
"bodySize",
"commentSize",
"listSize",
"settingSize"
].forEach(key=>{
const n=Number(settings[key]||2);
root.style.setProperty(
"--"+key,
sizes[key][Math.max(0,Math.min(4,n-1))]+"px"
);
});
document.body.classList.toggle("dark-mode",!!settings.darkMode);
document.getElementById("appTitle").textContent=settings.title;
/*
背景カラーはダークモードでは変更しない。
*/
applyBackground();
}
/* =========================
写真拡大
========================= */
function openPhoto(src){
const box=document.createElement("div");
box.className="photo-window";
const img=document.createElement("img");
img.src=src;
box.appendChild(img);
box.onclick=()=>box.remove();
document.body.appendChild(box);
}
/* =========================
ユーティリティ
========================= */
function today(){
const d=new Date();
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
}
function getWeekday(value){
if(!value)return "";
const [y,m,d]=value.split("-").map(Number);
const date=new Date(y,m-1,d);
if(
date.getFullYear()!==y||
date.getMonth()!==m-1||
date.getDate()!==d
)return "";
return ["日","月","火","水","木","金","土"][date.getDay()];
}
function updateWeekday(value){
const box=document.getElementById("weekdayDisplay");
if(!box)return;
const w=getWeekday(value);
box.textContent=w?`(${w})`:"";
}
function dateText(value){
if(!value)return "";
const [y,m,d]=value.split("-");
const w=getWeekday(value);
return w?`${y}年${m}月${d}日 (${w})`:`${y}年${m}月${d}日`;
}
function escapeHtml(value){
return String(value??"")
.replace(/&/g,"&")
.replace(/</g,"<")
.replace(/>/g,">");
}
function escapeAttr(value){
return escapeHtml(value).replace(/"/g,""");
}
function getFontFamily(name){
const f=FONTS.find(x=>x[0]===name);
return f?f[1]:"Yu Gothic";
}
function blobToDataURL(blob){
return new Promise((resolve,reject)=>{
const r=new FileReader();
r.onload=()=>resolve(r.result);
r.onerror=reject;
r.readAsDataURL(blob);
});
}
async function dataURLToBlob(dataURL){
return (await fetch(dataURL)).blob();
}
const permissionBackgrounds=[
`
radial-gradient(
circle at 20% 20%,
rgba(79,134,198,.25),
transparent 35%
),
radial-gradient(
circle at 80% 80%,
rgba(112,173,71,.12),
transparent 35%
),
linear-gradient(
135deg,
#171d25,
#20262e 50%,
#18211d
)
`,
`
radial-gradient(
circle at 18% 22%,
rgba(255,190,100,.38),
transparent 35%
),
radial-gradient(
circle at 82% 25%,
rgba(255,145,100,.30),
transparent 35%
),
radial-gradient(
circle at 70% 85%,
rgba(255,210,120,.28),
transparent 40%
),
linear-gradient(
135deg,
#fff5df 0%,
#ffe9d6 50%,
#fff7e8 100%
)
`,
`
radial-gradient(
circle at 20% 20%,
rgba(255,180,210,.42),
transparent 35%
),
radial-gradient(
circle at 80% 30%,
rgba(170,130,255,.38),
transparent 36%
),
radial-gradient(
circle at 65% 85%,
rgba(100,170,255,.28),
transparent 40%
),
linear-gradient(
135deg,
#fff0f5 0%,
#eee9ff 50%,
#e7f3ff 100%
)
`
];
function applyPermissionBackground(){
const screen=document.querySelector(".permission-screen");
if(!screen)return;
screen.style.background=`
radial-gradient(
circle at 20% 20%,
rgba(255,180,210,.42),
transparent 35%
),
radial-gradient(
circle at 80% 30%,
rgba(170,130,255,.38),
transparent 36%
),
radial-gradient(
circle at 65% 85%,
rgba(100,170,255,.28),
transparent 40%
),
linear-gradient(
135deg,
#fff0f5 0%,
#eee9ff 50%,
#e7f3ff 100%
)
`;
}
/* =========================
起動
========================= */
async function init(){
/* =========================
保存済みフォルダ取得
========================= */
if(!recordFolderHandle){
recordFolderHandle=await idbGet("__record_folder_handle__");
}
/* =========================
フォルダ未選択
========================= */
if(!recordFolderHandle){
document.body.classList.add("permission-mode");
document.getElementById("main").innerHTML=`
<div class="permission-screen">
<div class="permission-card">
<div class="permission-icon">
📁
</div>
<h2>記録管理フォルダ</h2>
<p>
最初に、作成済みの「記録管理」フォルダを指定してください。
</p>
<button id="selectRecordFolderBtn" class="action save">
記録管理フォルダを選択
</button>
</div>
</div>
`;
document.getElementById("selectRecordFolderBtn").onclick=async()=>{
const ok=await selectRecordFolder();
if(!ok)return;
await init();
};
return;
}
/* =========================
アクセス権限確認
========================= */
const permission=await recordFolderHandle.queryPermission({
mode:"readwrite"
});
if(permission!=="granted"){
document.body.classList.add("permission-mode");
document.getElementById("main").innerHTML=`
<div class="permission-screen">
<div class="permission-card">
<div class="permission-icon">
📁
</div>
<h2>記録管理フォルダ</h2>
<p>
記録管理フォルダへのアクセス許可が必要です。
</p>
<button id="grantRecordFolderBtn" class="action save">
記録管理フォルダへのアクセスを許可
</button>
</div>
</div>
`;
applyPermissionBackground();
document.getElementById("grantRecordFolderBtn").onclick=async()=>{
const permission=
await recordFolderHandle.requestPermission({
mode:"readwrite"
});
if(permission!=="granted"){
alert(
"記録管理フォルダへのアクセスを許可してください。"
);
return;
}
await init();
};
return;
}
/* =========================
フォルダの実在確認
========================= */
try{
const fileHandle=
await recordFolderHandle.getFileHandle(
"records.json",
{create:true}
);
await fileHandle.getFile();
}catch(e){
console.warn(
"以前の記録管理フォルダが利用できません。",
e
);
recordFolderHandle=null;
document.getElementById("main").innerHTML=`
<div class="permission-screen">
<div class="permission-card">
<div class="permission-icon">
📁
</div>
<h2>記録管理フォルダ</h2>
<p>
以前の記録管理フォルダを確認できませんでした。<br>
記録管理フォルダをもう一度選択してください。
</p>
<button id="selectRecordFolderBtn" class="action save">
記録管理フォルダを選択
</button>
</div>
</div>
`;
document.getElementById("selectRecordFolderBtn").onclick=async()=>{
const ok=await selectRecordFolder();
if(!ok)return;
await init();
};
return;
}
/* =========================
通常起動
========================= */
document.body.classList.remove("permission-mode");
settings=await loadSettings();
trips=await loadTrips();
backgroundImageId=
settings.backgroundImageId||null;
applySettings();
await applyBackground();
setActive("years");
showYears();
}
init();
</script>
</body>
</html>120,690 chars · codepastes.com