This commit is contained in:
		
							
								
								
									
										760
									
								
								aicard-web-oauth/src/App.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										760
									
								
								aicard-web-oauth/src/App.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,760 @@ | ||||
| .app { | ||||
|   min-height: 100vh; | ||||
|   background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%); | ||||
|   color: #333333; | ||||
| } | ||||
|  | ||||
| .app-header { | ||||
|   text-align: center; | ||||
|   padding: 40px 20px; | ||||
|   border-bottom: 1px solid #e9ecef; | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .app-nav { | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   gap: 8px; | ||||
|   padding: 20px; | ||||
|   background: rgba(0, 0, 0, 0.02); | ||||
|   border-bottom: 1px solid #e9ecef; | ||||
|   margin-bottom: 40px; | ||||
| } | ||||
|  | ||||
| .nav-button { | ||||
|   padding: 12px 20px; | ||||
|   border: 1px solid #dee2e6; | ||||
|   border-radius: 8px; | ||||
|   background: rgba(255, 255, 255, 0.8); | ||||
|   color: #6c757d; | ||||
|   font-weight: 500; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
|   backdrop-filter: blur(10px); | ||||
| } | ||||
|  | ||||
| .nav-button:hover { | ||||
|   background: rgba(102, 126, 234, 0.1); | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | ||||
|   color: #495057; | ||||
| } | ||||
|  | ||||
| .nav-button.active { | ||||
|   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|   color: white; | ||||
|   border: 1px solid #667eea; | ||||
|   box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4); | ||||
| } | ||||
|  | ||||
| .nav-button.active:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); | ||||
| } | ||||
|  | ||||
| .app-header h1 { | ||||
|   font-size: 48px; | ||||
|   margin: 0; | ||||
|   background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%); | ||||
|   -webkit-background-clip: text; | ||||
|   -webkit-text-fill-color: transparent; | ||||
|   background-clip: text; | ||||
| } | ||||
|  | ||||
| .app-header p { | ||||
|   color: #6c757d; | ||||
|   margin-top: 10px; | ||||
| } | ||||
|  | ||||
| .user-info { | ||||
|   position: absolute; | ||||
|   top: 20px; | ||||
|   right: 20px; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 15px; | ||||
| } | ||||
|  | ||||
| .user-handle { | ||||
|   color: #495057; | ||||
|   font-weight: bold; | ||||
|   background: rgba(102, 126, 234, 0.1); | ||||
|   padding: 6px 12px; | ||||
|   border-radius: 20px; | ||||
|   border: 1px solid #dee2e6; | ||||
| } | ||||
|  | ||||
| .login-button, | ||||
| .logout-button, | ||||
| .backup-button, | ||||
| .token-button { | ||||
|   padding: 8px 16px; | ||||
|   border: none; | ||||
|   border-radius: 8px; | ||||
|   font-size: 12px; | ||||
|   font-weight: bold; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
|   margin-left: 8px; | ||||
| } | ||||
|  | ||||
| .login-button { | ||||
|   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|   color: white; | ||||
|   border: 1px solid #667eea; | ||||
| } | ||||
|  | ||||
| .backup-button { | ||||
|   background: linear-gradient(135deg, #28a745 0%, #20c997 100%); | ||||
|   color: white; | ||||
|   border: 1px solid #28a745; | ||||
| } | ||||
|  | ||||
| .token-button { | ||||
|   background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); | ||||
|   color: white; | ||||
|   border: 1px solid #ffc107; | ||||
| } | ||||
|  | ||||
| .logout-button { | ||||
|   background: rgba(108, 117, 125, 0.1); | ||||
|   color: #495057; | ||||
|   border: 1px solid #dee2e6; | ||||
| } | ||||
|  | ||||
| .login-button:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); | ||||
| } | ||||
|  | ||||
| .backup-button:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4); | ||||
| } | ||||
|  | ||||
| .token-button:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(255, 193, 7, 0.4); | ||||
| } | ||||
|  | ||||
| .logout-button:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | ||||
|   background: rgba(108, 117, 125, 0.2); | ||||
| } | ||||
|  | ||||
| .loading { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   height: 100vh; | ||||
|   font-size: 24px; | ||||
|   color: #667eea; | ||||
| } | ||||
|  | ||||
| .app-main { | ||||
|   max-width: 1200px; | ||||
|   margin: 0 auto; | ||||
|   padding: 40px 20px; | ||||
| } | ||||
|  | ||||
| .gacha-section { | ||||
|   text-align: center; | ||||
|   margin-bottom: 60px; | ||||
| } | ||||
|  | ||||
| .gacha-section h2 { | ||||
|   font-size: 32px; | ||||
|   margin-bottom: 30px; | ||||
| } | ||||
|  | ||||
| .gacha-buttons { | ||||
|   display: flex; | ||||
|   gap: 20px; | ||||
|   justify-content: center; | ||||
|   flex-wrap: wrap; | ||||
| } | ||||
|  | ||||
| .gacha-button { | ||||
|   padding: 20px 40px; | ||||
|   font-size: 18px; | ||||
|   font-weight: bold; | ||||
|   border: none; | ||||
|   border-radius: 12px; | ||||
|   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|   color: white; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
|   box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); | ||||
| } | ||||
|  | ||||
| .gacha-button:hover:not(:disabled) { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); | ||||
| } | ||||
|  | ||||
| .gacha-button:disabled { | ||||
|   opacity: 0.5; | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .gacha-button-premium { | ||||
|   background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); | ||||
|   position: relative; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .gacha-button-premium::before { | ||||
|   content: ''; | ||||
|   position: absolute; | ||||
|   top: -50%; | ||||
|   left: -50%; | ||||
|   width: 200%; | ||||
|   height: 200%; | ||||
|   background: linear-gradient( | ||||
|     45deg, | ||||
|     transparent 30%, | ||||
|     rgba(255, 255, 255, 0.2) 50%, | ||||
|     transparent 70% | ||||
|   ); | ||||
|   animation: shimmer 3s infinite; | ||||
| } | ||||
|  | ||||
| .collection-section h2 { | ||||
|   font-size: 32px; | ||||
|   text-align: center; | ||||
|   margin-bottom: 30px; | ||||
| } | ||||
|  | ||||
| .card-grid { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | ||||
|   gap: 30px; | ||||
|   justify-items: center; | ||||
| } | ||||
|  | ||||
| .empty-message { | ||||
|   text-align: center; | ||||
|   color: #6c757d; | ||||
|   font-size: 18px; | ||||
|   margin-top: 40px; | ||||
| } | ||||
|  | ||||
| .error { | ||||
|   color: #ff4757; | ||||
|   text-align: center; | ||||
|   margin-top: 20px; | ||||
| } | ||||
|  | ||||
| @keyframes shimmer { | ||||
|   0% { transform: translateX(-100%) rotate(45deg); } | ||||
|   100% { transform: translateX(100%) rotate(45deg); } | ||||
| } | ||||
|  | ||||
| /* Comment System Styles */ | ||||
| .comment-section { | ||||
|   max-width: 800px; | ||||
|   margin: 0 auto; | ||||
|   padding: 20px; | ||||
| } | ||||
|  | ||||
| .auth-section { | ||||
|   background: #f8f9fa; | ||||
|   border: 1px solid #e9ecef; | ||||
|   border-radius: 8px; | ||||
|   padding: 20px; | ||||
|   margin-bottom: 20px; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .atproto-button { | ||||
|   background: #1185fe; | ||||
|   color: white; | ||||
|   border: none; | ||||
|   padding: 12px 24px; | ||||
|   border-radius: 6px; | ||||
|   font-size: 16px; | ||||
|   font-weight: bold; | ||||
|   cursor: pointer; | ||||
|   margin-bottom: 15px; | ||||
|   transition: all 0.3s ease; | ||||
| } | ||||
|  | ||||
| .atproto-button:hover { | ||||
|   background: #0d6efd; | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(17, 133, 254, 0.4); | ||||
| } | ||||
|  | ||||
| .username-input-section { | ||||
|   margin: 15px 0; | ||||
| } | ||||
|  | ||||
| .handle-input { | ||||
|   width: 300px; | ||||
|   max-width: 100%; | ||||
|   padding: 10px; | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 6px; | ||||
|   font-size: 14px; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .auth-hint { | ||||
|   color: #6c757d; | ||||
|   font-size: 14px; | ||||
|   margin: 10px 0 0 0; | ||||
| } | ||||
|  | ||||
| .user-section { | ||||
|   background: #e8f5e8; | ||||
|   border: 1px solid #4caf50; | ||||
|   border-radius: 8px; | ||||
|   padding: 20px; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .user-section .user-info { | ||||
|   position: static; | ||||
|   display: block; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .user-profile { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 15px; | ||||
|   margin-bottom: 15px; | ||||
| } | ||||
|  | ||||
| .user-avatar { | ||||
|   width: 48px; | ||||
|   height: 48px; | ||||
|   border-radius: 50%; | ||||
|   object-fit: cover; | ||||
|   border: 2px solid #4caf50; | ||||
| } | ||||
|  | ||||
| .user-details h3 { | ||||
|   margin: 0 0 5px 0; | ||||
|   color: #333; | ||||
|   font-size: 18px; | ||||
| } | ||||
|  | ||||
| .user-section .user-info h3 { | ||||
|   margin: 0 0 10px 0; | ||||
|   color: #333; | ||||
| } | ||||
|  | ||||
| .user-section .user-handle { | ||||
|   background: rgba(76, 175, 80, 0.1); | ||||
|   color: #2e7d32; | ||||
|   border: 1px solid #4caf50; | ||||
| } | ||||
|  | ||||
| .user-section .user-did { | ||||
|   font-family: monospace; | ||||
|   font-size: 0.8em; | ||||
|   color: #666; | ||||
|   background: #f1f3f4; | ||||
|   padding: 4px 8px; | ||||
|   border-radius: 4px; | ||||
|   margin-top: 5px; | ||||
|   word-break: break-all; | ||||
| } | ||||
|  | ||||
| .comment-form { | ||||
|   background: #fff; | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 8px; | ||||
|   padding: 20px; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .comment-form h3 { | ||||
|   margin: 0 0 15px 0; | ||||
|   color: #333; | ||||
| } | ||||
|  | ||||
| .comment-form textarea { | ||||
|   width: 100%; | ||||
|   padding: 12px; | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 6px; | ||||
|   font-family: inherit; | ||||
|   font-size: 14px; | ||||
|   resize: vertical; | ||||
|   box-sizing: border-box; | ||||
|   min-height: 100px; | ||||
| } | ||||
|  | ||||
| .comment-form textarea:focus { | ||||
|   border-color: #1185fe; | ||||
|   outline: none; | ||||
|   box-shadow: 0 0 0 2px rgba(17, 133, 254, 0.1); | ||||
| } | ||||
|  | ||||
| .form-actions { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   margin-top: 10px; | ||||
| } | ||||
|  | ||||
| .char-count { | ||||
|   color: #666; | ||||
|   font-size: 0.9em; | ||||
| } | ||||
|  | ||||
| .post-button { | ||||
|   background: #28a745; | ||||
|   color: white; | ||||
|   border: none; | ||||
|   padding: 10px 20px; | ||||
|   border-radius: 6px; | ||||
|   cursor: pointer; | ||||
|   font-size: 14px; | ||||
|   font-weight: bold; | ||||
|   transition: all 0.3s ease; | ||||
| } | ||||
|  | ||||
| .post-button:hover:not(:disabled) { | ||||
|   background: #218838; | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4); | ||||
| } | ||||
|  | ||||
| .post-button:disabled { | ||||
|   background: #6c757d; | ||||
|   cursor: not-allowed; | ||||
|   transform: none; | ||||
|   box-shadow: none; | ||||
| } | ||||
|  | ||||
| .comments-list { | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 8px; | ||||
|   padding: 20px; | ||||
| } | ||||
|  | ||||
| .comments-header { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .comments-header h3 { | ||||
|   margin: 0; | ||||
|   color: #333; | ||||
| } | ||||
|  | ||||
| .comments-controls { | ||||
|   display: flex; | ||||
|   gap: 10px; | ||||
| } | ||||
|  | ||||
| .comments-toggle-button { | ||||
|   background: #1185fe; | ||||
|   color: white; | ||||
|   border: none; | ||||
|   padding: 8px 16px; | ||||
|   border-radius: 6px; | ||||
|   cursor: pointer; | ||||
|   font-size: 14px; | ||||
|   font-weight: bold; | ||||
|   transition: all 0.3s ease; | ||||
| } | ||||
|  | ||||
| .comments-toggle-button:hover { | ||||
|   background: #0d6efd; | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(17, 133, 254, 0.4); | ||||
| } | ||||
|  | ||||
| .comment-item { | ||||
|   border: 1px solid #e9ecef; | ||||
|   border-radius: 6px; | ||||
|   padding: 15px; | ||||
|   margin-bottom: 15px; | ||||
|   background: #fff; | ||||
| } | ||||
|  | ||||
| .comment-item:last-child { | ||||
|   margin-bottom: 0; | ||||
| } | ||||
|  | ||||
| .comment-header { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 10px; | ||||
|   margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .comment-avatar { | ||||
|   width: 32px; | ||||
|   height: 32px; | ||||
|   border-radius: 50%; | ||||
|   object-fit: cover; | ||||
|   border: 1px solid #ddd; | ||||
| } | ||||
|  | ||||
| .comment-author-info { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 2px; | ||||
|   flex: 1; | ||||
| } | ||||
|  | ||||
| .comment-author { | ||||
|   font-weight: bold; | ||||
|   color: #333; | ||||
|   font-size: 0.95em; | ||||
| } | ||||
|  | ||||
| .comment-handle { | ||||
|   color: #666; | ||||
|   font-size: 0.8em; | ||||
| } | ||||
|  | ||||
| .comment-date { | ||||
|   color: #666; | ||||
|   font-size: 0.9em; | ||||
|   margin-left: auto; | ||||
| } | ||||
|  | ||||
| .delete-button { | ||||
|   background: none; | ||||
|   border: none; | ||||
|   cursor: pointer; | ||||
|   font-size: 16px; | ||||
|   padding: 4px 8px; | ||||
|   border-radius: 4px; | ||||
|   transition: all 0.3s ease; | ||||
|   margin-left: 8px; | ||||
| } | ||||
|  | ||||
| .delete-button:hover { | ||||
|   background: rgba(220, 53, 69, 0.1); | ||||
|   transform: scale(1.1); | ||||
| } | ||||
|  | ||||
| .comment-content { | ||||
|   line-height: 1.5; | ||||
|   color: #333; | ||||
|   margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .comment-meta { | ||||
|   padding: 8px; | ||||
|   background: #f1f3f4; | ||||
|   border-radius: 4px; | ||||
|   font-size: 0.8em; | ||||
|   color: #666; | ||||
| } | ||||
|  | ||||
| .comment-meta small { | ||||
|   font-family: monospace; | ||||
| } | ||||
|  | ||||
| .no-comments { | ||||
|   text-align: center; | ||||
|   color: #666; | ||||
|   font-style: italic; | ||||
|   padding: 40px; | ||||
| } | ||||
|  | ||||
| .error { | ||||
|   background: #f8d7da; | ||||
|   color: #721c24; | ||||
|   border: 1px solid #f5c6cb; | ||||
|   border-radius: 4px; | ||||
|   padding: 10px; | ||||
|   margin-top: 10px; | ||||
| } | ||||
|  | ||||
| /* Admin Section Styles */ | ||||
| .admin-section { | ||||
|   background: #e3f2fd; | ||||
|   border: 1px solid #2196f3; | ||||
|   border-radius: 8px; | ||||
|   padding: 20px; | ||||
|   margin-top: 20px; | ||||
| } | ||||
|  | ||||
| .admin-section h3 { | ||||
|   margin: 0 0 15px 0; | ||||
|   color: #1976d2; | ||||
|   font-size: 16px; | ||||
| } | ||||
|  | ||||
| .user-list-form { | ||||
|   background: #fff; | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 8px; | ||||
|   padding: 15px; | ||||
| } | ||||
|  | ||||
| .user-list-form textarea { | ||||
|   width: 100%; | ||||
|   padding: 12px; | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 6px; | ||||
|   font-family: inherit; | ||||
|   font-size: 14px; | ||||
|   resize: vertical; | ||||
|   box-sizing: border-box; | ||||
|   min-height: 80px; | ||||
| } | ||||
|  | ||||
| .user-list-form textarea:focus { | ||||
|   border-color: #2196f3; | ||||
|   outline: none; | ||||
|   box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1); | ||||
| } | ||||
|  | ||||
| .admin-hint { | ||||
|   color: #666; | ||||
|   font-size: 0.9em; | ||||
|   font-style: italic; | ||||
| } | ||||
|  | ||||
| /* User List Records Styles */ | ||||
| .user-list-records { | ||||
|   margin-top: 20px; | ||||
| } | ||||
|  | ||||
| .user-list-records h4 { | ||||
|   margin: 0 0 15px 0; | ||||
|   color: #1976d2; | ||||
|   font-size: 14px; | ||||
| } | ||||
|  | ||||
| .no-user-lists { | ||||
|   text-align: center; | ||||
|   color: #666; | ||||
|   font-style: italic; | ||||
|   padding: 20px; | ||||
| } | ||||
|  | ||||
| .user-list-item { | ||||
|   border: 1px solid #e3f2fd; | ||||
|   border-radius: 6px; | ||||
|   padding: 12px; | ||||
|   margin-bottom: 10px; | ||||
|   background: #fff; | ||||
| } | ||||
|  | ||||
| .user-list-item:last-child { | ||||
|   margin-bottom: 0; | ||||
| } | ||||
|  | ||||
| .user-list-header { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   margin-bottom: 8px; | ||||
| } | ||||
|  | ||||
| .user-list-actions { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 8px; | ||||
| } | ||||
|  | ||||
| .user-list-date { | ||||
|   color: #666; | ||||
|   font-size: 0.9em; | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| .user-list-content { | ||||
|   margin-top: 8px; | ||||
| } | ||||
|  | ||||
| .user-handles { | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|   gap: 8px; | ||||
|   margin-bottom: 8px; | ||||
| } | ||||
|  | ||||
| .user-handle-tag { | ||||
|   background: #e3f2fd; | ||||
|   color: #1976d2; | ||||
|   padding: 4px 8px; | ||||
|   border-radius: 12px; | ||||
|   font-size: 0.85em; | ||||
|   font-weight: 500; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 4px; | ||||
| } | ||||
|  | ||||
| .pds-info { | ||||
|   color: #666; | ||||
|   font-size: 0.75em; | ||||
|   font-weight: normal; | ||||
| } | ||||
|  | ||||
| .user-list-meta { | ||||
|   font-size: 0.8em; | ||||
|   color: #666; | ||||
|   background: #f8f9fa; | ||||
|   padding: 6px 8px; | ||||
|   border-radius: 4px; | ||||
|   line-height: 1.4; | ||||
| } | ||||
|  | ||||
| .user-list-meta small { | ||||
|   font-family: monospace; | ||||
| } | ||||
|  | ||||
| /* JSON Display Styles */ | ||||
| .json-button { | ||||
|   background: #4caf50; | ||||
|   color: white; | ||||
|   border: none; | ||||
|   padding: 4px 8px; | ||||
|   border-radius: 4px; | ||||
|   cursor: pointer; | ||||
|   font-size: 12px; | ||||
|   font-weight: 500; | ||||
|   transition: all 0.3s ease; | ||||
| } | ||||
|  | ||||
| .json-button:hover { | ||||
|   background: #45a049; | ||||
|   transform: scale(1.05); | ||||
| } | ||||
|  | ||||
| .json-display { | ||||
|   margin-top: 12px; | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 6px; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .json-display h5 { | ||||
|   margin: 0; | ||||
|   padding: 8px 12px; | ||||
|   background: #f1f3f4; | ||||
|   border-bottom: 1px solid #ddd; | ||||
|   font-size: 0.9em; | ||||
|   color: #333; | ||||
| } | ||||
|  | ||||
| .json-content { | ||||
|   margin: 0; | ||||
|   padding: 12px; | ||||
|   background: #f8f9fa; | ||||
|   font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; | ||||
|   font-size: 0.8em; | ||||
|   line-height: 1.4; | ||||
|   overflow-x: auto; | ||||
|   white-space: pre-wrap; | ||||
|   word-break: break-word; | ||||
|   color: #333; | ||||
|   max-height: 400px; | ||||
|   overflow-y: auto; | ||||
| } | ||||
							
								
								
									
										946
									
								
								aicard-web-oauth/src/App.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										946
									
								
								aicard-web-oauth/src/App.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,946 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { OAuthCallback } from './components/OAuthCallback'; | ||||
| import { authService, User } from './services/auth'; | ||||
| import { atprotoOAuthService } from './services/atproto-oauth'; | ||||
| import './App.css'; | ||||
|  | ||||
| function App() { | ||||
|   console.log('APP COMPONENT LOADED - Console working!'); | ||||
|   console.log('Current timestamp:', new Date().toISOString()); | ||||
|    | ||||
|   // Immediately log URL information on every page load | ||||
|   console.log('IMMEDIATE URL CHECK:'); | ||||
|   console.log('- href:', window.location.href); | ||||
|   console.log('- pathname:', window.location.pathname);  | ||||
|   console.log('- search:', window.location.search); | ||||
|   console.log('- hash:', window.location.hash); | ||||
|    | ||||
|   // Also show URL info via alert if it contains OAuth parameters | ||||
|   if (window.location.search.includes('code=') || window.location.search.includes('state=')) { | ||||
|     const urlInfo = `OAuth callback detected!\n\nURL: ${window.location.href}\nSearch: ${window.location.search}`; | ||||
|     alert(urlInfo); | ||||
|     console.log('OAuth callback URL detected!'); | ||||
|   } else { | ||||
|     // Check if we have stored OAuth info from previous steps | ||||
|     const preOAuthUrl = sessionStorage.getItem('pre_oauth_url'); | ||||
|     const storedState = sessionStorage.getItem('oauth_state'); | ||||
|     const storedCodeVerifier = sessionStorage.getItem('oauth_code_verifier'); | ||||
|      | ||||
|     console.log('=== OAUTH SESSION STORAGE CHECK ==='); | ||||
|     console.log('Pre-OAuth URL:', preOAuthUrl); | ||||
|     console.log('Stored state:', storedState); | ||||
|     console.log('Stored code verifier:', storedCodeVerifier ? 'Present' : 'Missing'); | ||||
|     console.log('=== END SESSION STORAGE CHECK ==='); | ||||
|   } | ||||
|    | ||||
|   const [user, setUser] = useState<User | null>(null); | ||||
|   const [isLoading, setIsLoading] = useState(true); | ||||
|   const [comments, setComments] = useState<any[]>([]); | ||||
|   const [commentText, setCommentText] = useState(''); | ||||
|   const [isPosting, setIsPosting] = useState(false); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [handleInput, setHandleInput] = useState(''); | ||||
|   const [userListInput, setUserListInput] = useState(''); | ||||
|   const [isPostingUserList, setIsPostingUserList] = useState(false); | ||||
|   const [userListRecords, setUserListRecords] = useState<any[]>([]); | ||||
|   const [showJsonFor, setShowJsonFor] = useState<string | null>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // Setup Jetstream WebSocket for real-time comments (optional) | ||||
|     const setupJetstream = () => { | ||||
|       try { | ||||
|         const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe'); | ||||
|          | ||||
|         ws.onopen = () => { | ||||
|           console.log('Jetstream connected'); | ||||
|           ws.send(JSON.stringify({ | ||||
|             wantedCollections: ['ai.syui.log'] | ||||
|           })); | ||||
|         }; | ||||
|          | ||||
|         ws.onmessage = (event) => { | ||||
|           try { | ||||
|             const data = JSON.parse(event.data); | ||||
|             if (data.collection === 'ai.syui.log' && data.commit?.operation === 'create') { | ||||
|               console.log('New comment detected via Jetstream:', data); | ||||
|               // Optionally reload comments | ||||
|               // loadAllComments(window.location.href); | ||||
|             } | ||||
|           } catch (err) { | ||||
|             console.warn('Failed to parse Jetstream message:', err); | ||||
|           } | ||||
|         }; | ||||
|          | ||||
|         ws.onerror = (err) => { | ||||
|           console.warn('Jetstream error:', err); | ||||
|         }; | ||||
|          | ||||
|         return ws; | ||||
|       } catch (err) { | ||||
|         console.warn('Failed to setup Jetstream:', err); | ||||
|         return null; | ||||
|       } | ||||
|     }; | ||||
|      | ||||
|     // Jetstream + Cache example | ||||
|     const jetstream = setupJetstream(); | ||||
|      | ||||
|     // キャッシュからコメント読み込み | ||||
|     const loadCachedComments = () => { | ||||
|       const cached = localStorage.getItem('cached_comments_' + window.location.pathname); | ||||
|       if (cached) { | ||||
|         const { comments: cachedComments, timestamp } = JSON.parse(cached); | ||||
|         // 5分以内のキャッシュなら使用 | ||||
|         if (Date.now() - timestamp < 5 * 60 * 1000) { | ||||
|           setComments(cachedComments); | ||||
|           return true; | ||||
|         } | ||||
|       } | ||||
|       return false; | ||||
|     }; | ||||
|      | ||||
|     // キャッシュがなければ、ATProtoから取得 | ||||
|     if (!loadCachedComments()) { | ||||
|       loadAllComments(window.location.href); | ||||
|     } | ||||
|  | ||||
|     // Handle popstate events for mock OAuth flow | ||||
|     const handlePopState = () => { | ||||
|       const urlParams = new URLSearchParams(window.location.search); | ||||
|       const isOAuthCallback = urlParams.has('code') && urlParams.has('state'); | ||||
|        | ||||
|       if (isOAuthCallback) { | ||||
|         // Force re-render to handle OAuth callback | ||||
|         window.location.reload(); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     window.addEventListener('popstate', handlePopState); | ||||
|  | ||||
|     // Check if this is an OAuth callback | ||||
|     const urlParams = new URLSearchParams(window.location.search); | ||||
|     const isOAuthCallback = urlParams.has('code') && urlParams.has('state'); | ||||
|      | ||||
|     if (isOAuthCallback) { | ||||
|       return; // Let OAuthCallback component handle this | ||||
|     } | ||||
|  | ||||
|     // Check existing sessions | ||||
|     const checkAuth = async () => { | ||||
|       // First check OAuth session using official BrowserOAuthClient | ||||
|       console.log('Checking OAuth session...'); | ||||
|       const oauthResult = await atprotoOAuthService.checkSession(); | ||||
|       console.log('OAuth checkSession result:', oauthResult); | ||||
|        | ||||
|       if (oauthResult) { | ||||
|         console.log('OAuth session found:', oauthResult); | ||||
|         // Ensure handle is not DID | ||||
|         const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle; | ||||
|          | ||||
|         // Get user profile including avatar | ||||
|         const userProfile = await getUserProfile(oauthResult.did, handle); | ||||
|         setUser(userProfile); | ||||
|          | ||||
|         // Load all comments for display (this will be the default view) | ||||
|         loadAllComments(window.location.href); | ||||
|          | ||||
|         // Load user list records if admin | ||||
|         if (userProfile.did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') { | ||||
|           loadUserListRecords(); | ||||
|         } | ||||
|          | ||||
|         setIsLoading(false); | ||||
|         return; | ||||
|       } else { | ||||
|         console.log('No OAuth session found'); | ||||
|       } | ||||
|  | ||||
|       // Fallback to legacy auth | ||||
|       const verifiedUser = await authService.verify(); | ||||
|       if (verifiedUser) { | ||||
|         setUser(verifiedUser); | ||||
|          | ||||
|         // Load all comments for display (this will be the default view) | ||||
|         loadAllComments(window.location.href); | ||||
|          | ||||
|         // Load user list records if admin | ||||
|         if (verifiedUser.did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') { | ||||
|           loadUserListRecords(); | ||||
|         } | ||||
|       } | ||||
|       setIsLoading(false); | ||||
|     }; | ||||
|  | ||||
|     checkAuth(); | ||||
|  | ||||
|     return () => { | ||||
|       window.removeEventListener('popstate', handlePopState); | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   const getUserProfile = async (did: string, handle: string): Promise<User> => { | ||||
|     try { | ||||
|       const agent = atprotoOAuthService.getAgent(); | ||||
|       if (agent) { | ||||
|         const profile = await agent.getProfile({ actor: handle }); | ||||
|         return { | ||||
|           did: did, | ||||
|           handle: handle, | ||||
|           avatar: profile.data.avatar, | ||||
|           displayName: profile.data.displayName || handle | ||||
|         }; | ||||
|       } | ||||
|     } catch (error) { | ||||
|       console.error('Failed to get user profile:', error); | ||||
|     } | ||||
|      | ||||
|     // Fallback to basic user info | ||||
|     return { | ||||
|       did: did, | ||||
|       handle: handle, | ||||
|       avatar: generatePlaceholderAvatar(handle), | ||||
|       displayName: handle | ||||
|     }; | ||||
|   }; | ||||
|  | ||||
|   const generatePlaceholderAvatar = (handle: string): string => { | ||||
|     const initial = handle ? handle.charAt(0).toUpperCase() : 'U'; | ||||
|     return `https://via.placeholder.com/48x48/1185fe/ffffff?text=${initial}`; | ||||
|   }; | ||||
|  | ||||
|   const loadUserComments = async (did: string) => { | ||||
|     try { | ||||
|       console.log('Loading comments for DID:', did); | ||||
|       const agent = atprotoOAuthService.getAgent(); | ||||
|       if (!agent) { | ||||
|         console.log('No agent available'); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Get comments from current user | ||||
|       const response = await agent.api.com.atproto.repo.listRecords({ | ||||
|         repo: did, | ||||
|         collection: 'ai.syui.log', | ||||
|         limit: 100, | ||||
|       }); | ||||
|  | ||||
|       console.log('User comments loaded:', response.data); | ||||
|       const userComments = response.data.records || []; | ||||
|        | ||||
|       // Enhance comments with profile information if missing | ||||
|       const enhancedComments = await Promise.all( | ||||
|         userComments.map(async (record) => { | ||||
|           if (!record.value.author?.avatar && record.value.author?.handle) { | ||||
|             try { | ||||
|               const profile = await agent.getProfile({ actor: record.value.author.handle }); | ||||
|               return { | ||||
|                 ...record, | ||||
|                 value: { | ||||
|                   ...record.value, | ||||
|                   author: { | ||||
|                     ...record.value.author, | ||||
|                     avatar: profile.data.avatar, | ||||
|                     displayName: profile.data.displayName || record.value.author.handle, | ||||
|                   } | ||||
|                 } | ||||
|               }; | ||||
|             } catch (err) { | ||||
|               console.warn('Failed to enhance comment with profile:', err); | ||||
|               return record; | ||||
|             } | ||||
|           } | ||||
|           return record; | ||||
|         }) | ||||
|       ); | ||||
|        | ||||
|       setComments(enhancedComments); | ||||
|     } catch (err) { | ||||
|       console.error('Failed to load comments:', err); | ||||
|       setComments([]); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // JSONからユーザーリストを取得 | ||||
|   const loadUsersFromRecord = async () => { | ||||
|     try { | ||||
|       // 管理者のユーザーリストを取得 (ai.syui.log.user collection) | ||||
|       const adminDid = 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; // syui.ai | ||||
|       const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=ai.syui.log.user&limit=100`); | ||||
|        | ||||
|       if (!response.ok) { | ||||
|         console.warn('Failed to fetch user list from admin, using default users'); | ||||
|         return getDefaultUsers(); | ||||
|       } | ||||
|        | ||||
|       const data = await response.json(); | ||||
|       const userRecords = data.records || []; | ||||
|        | ||||
|       if (userRecords.length === 0) { | ||||
|         return getDefaultUsers(); | ||||
|       } | ||||
|        | ||||
|       // レコードからユーザーリストを構築し、プレースホルダーDIDを実際のDIDに解決 | ||||
|       const allUsers = []; | ||||
|       for (const record of userRecords) { | ||||
|         if (record.value.users) { | ||||
|           // プレースホルダーDIDを実際のDIDに解決 | ||||
|           const resolvedUsers = await Promise.all( | ||||
|             record.value.users.map(async (user) => { | ||||
|               if (user.did && user.did.includes('-placeholder')) { | ||||
|                 console.log(`Resolving placeholder DID for ${user.handle}`); | ||||
|                 try { | ||||
|                   const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(user.handle)}`); | ||||
|                   if (profileResponse.ok) { | ||||
|                     const profileData = await profileResponse.json(); | ||||
|                     if (profileData.did) { | ||||
|                       console.log(`Resolved ${user.handle}: ${user.did} -> ${profileData.did}`); | ||||
|                       return { | ||||
|                         ...user, | ||||
|                         did: profileData.did | ||||
|                       }; | ||||
|                     } | ||||
|                   } | ||||
|                 } catch (err) { | ||||
|                   console.warn(`Failed to resolve DID for ${user.handle}:`, err); | ||||
|                 } | ||||
|               } | ||||
|               return user; | ||||
|             }) | ||||
|           ); | ||||
|           allUsers.push(...resolvedUsers); | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       console.log('Loaded and resolved users from admin records:', allUsers); | ||||
|       return allUsers; | ||||
|     } catch (err) { | ||||
|       console.warn('Failed to load users from records, using defaults:', err); | ||||
|       return getDefaultUsers(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // ユーザーリスト一覧を読み込み | ||||
|   const loadUserListRecords = async () => { | ||||
|     try { | ||||
|       console.log('Loading user list records...'); | ||||
|       const adminDid = 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; // syui.ai | ||||
|       const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=ai.syui.log.user&limit=100`); | ||||
|        | ||||
|       if (!response.ok) { | ||||
|         console.warn('Failed to fetch user list records'); | ||||
|         setUserListRecords([]); | ||||
|         return; | ||||
|       } | ||||
|        | ||||
|       const data = await response.json(); | ||||
|       const records = data.records || []; | ||||
|        | ||||
|       // 新しい順にソート | ||||
|       const sortedRecords = records.sort((a, b) =>  | ||||
|         new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() | ||||
|       ); | ||||
|        | ||||
|       console.log(`Loaded ${sortedRecords.length} user list records`); | ||||
|       setUserListRecords(sortedRecords); | ||||
|     } catch (err) { | ||||
|       console.error('Failed to load user list records:', err); | ||||
|       setUserListRecords([]); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const getDefaultUsers = () => { | ||||
|     return [ | ||||
|       // bsky.social - 実際のDIDを使用 | ||||
|       { did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn', handle: 'syui.ai', pds: 'https://bsky.social' }, | ||||
|       // 他のユーザーは実際のDIDが不明なので、実在するユーザーのみ含める | ||||
|     ]; | ||||
|   }; | ||||
|  | ||||
|   // 新しい関数: 全ユーザーからコメントを収集 | ||||
|   const loadAllComments = async (pageUrl?: string) => { | ||||
|     try { | ||||
|       console.log('Loading comments from all users...'); | ||||
|        | ||||
|       // ユーザーリストを動的に取得 | ||||
|       const knownUsers = await loadUsersFromRecord(); | ||||
|  | ||||
|       const allComments = []; | ||||
|  | ||||
|       // 各ユーザーからコメントを収集 | ||||
|       for (const user of knownUsers) { | ||||
|         try { | ||||
|           console.log(`Fetching comments from user: ${user.handle} (${user.did}) at ${user.pds}`); | ||||
|            | ||||
|           // Public API使用(認証不要) | ||||
|           const response = await fetch(`${user.pds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(user.did)}&collection=ai.syui.log&limit=100`); | ||||
|            | ||||
|           if (!response.ok) { | ||||
|             console.warn(`Failed to fetch from ${user.handle} (${response.status}): ${response.statusText}`); | ||||
|             continue; | ||||
|           } | ||||
|            | ||||
|           const data = await response.json(); | ||||
|           const userComments = data.records || []; | ||||
|           console.log(`Found ${userComments.length} comments from ${user.handle}`); | ||||
|            | ||||
|           // ページURLでフィルタリング(指定された場合) | ||||
|           const filteredComments = pageUrl  | ||||
|             ? userComments.filter(record => record.value.url === pageUrl) | ||||
|             : userComments; | ||||
|  | ||||
|           console.log(`After URL filtering: ${filteredComments.length} comments from ${user.handle}`); | ||||
|           allComments.push(...filteredComments); | ||||
|         } catch (err) { | ||||
|           console.warn(`Failed to load comments from ${user.handle}:`, err); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       // 時間順にソート(新しい順) | ||||
|       const sortedComments = allComments.sort((a, b) =>  | ||||
|         new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() | ||||
|       ); | ||||
|  | ||||
|       // プロフィール情報で拡張(認証なしでも取得可能) | ||||
|       const enhancedComments = await Promise.all( | ||||
|         sortedComments.map(async (record) => { | ||||
|           if (!record.value.author?.avatar && record.value.author?.handle) { | ||||
|             try { | ||||
|               // Public API でプロフィール取得 | ||||
|               const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.handle)}`); | ||||
|                | ||||
|               if (profileResponse.ok) { | ||||
|                 const profileData = await profileResponse.json(); | ||||
|                 return { | ||||
|                   ...record, | ||||
|                   value: { | ||||
|                     ...record.value, | ||||
|                     author: { | ||||
|                       ...record.value.author, | ||||
|                       avatar: profileData.avatar, | ||||
|                       displayName: profileData.displayName || record.value.author.handle, | ||||
|                     } | ||||
|                   } | ||||
|                 }; | ||||
|               } | ||||
|             } catch (err) { | ||||
|               console.warn('Failed to enhance comment with profile:', err); | ||||
|             } | ||||
|           } | ||||
|           return record; | ||||
|         }) | ||||
|       ); | ||||
|  | ||||
|       console.log(`Loaded ${enhancedComments.length} comments from all users`); | ||||
|        | ||||
|       // デバッグ情報を追加 | ||||
|       console.log('Final enhanced comments:', enhancedComments); | ||||
|       console.log('Known users used:', knownUsers); | ||||
|        | ||||
|       setComments(enhancedComments); | ||||
|        | ||||
|       // キャッシュに保存(5分間有効) | ||||
|       if (pageUrl) { | ||||
|         const cacheKey = 'cached_comments_' + new URL(pageUrl).pathname; | ||||
|         const cacheData = { | ||||
|           comments: enhancedComments, | ||||
|           timestamp: Date.now() | ||||
|         }; | ||||
|         localStorage.setItem(cacheKey, JSON.stringify(cacheData)); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       console.error('Failed to load all comments:', err); | ||||
|       setComments([]); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|  | ||||
|   const handlePostComment = async () => { | ||||
|     if (!user || !commentText.trim()) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setIsPosting(true); | ||||
|     setError(null); | ||||
|  | ||||
|     try { | ||||
|       const agent = atprotoOAuthService.getAgent(); | ||||
|       if (!agent) { | ||||
|         throw new Error('No agent available'); | ||||
|       } | ||||
|  | ||||
|       // Create comment record with ISO datetime rkey | ||||
|       const now = new Date(); | ||||
|       const rkey = now.toISOString().replace(/[:.]/g, '-'); // Replace : and . with - for valid rkey | ||||
|        | ||||
|       const record = { | ||||
|         $type: 'ai.syui.log', | ||||
|         text: commentText, | ||||
|         url: window.location.href, | ||||
|         createdAt: now.toISOString(), | ||||
|         author: { | ||||
|           did: user.did, | ||||
|           handle: user.handle, | ||||
|           avatar: user.avatar, | ||||
|           displayName: user.displayName || user.handle, | ||||
|         }, | ||||
|       }; | ||||
|  | ||||
|       // Post to ATProto with rkey | ||||
|       const response = await agent.api.com.atproto.repo.putRecord({ | ||||
|         repo: user.did, | ||||
|         collection: 'ai.syui.log', | ||||
|         rkey: rkey, | ||||
|         record: record, | ||||
|       }); | ||||
|  | ||||
|       console.log('Comment posted:', response); | ||||
|  | ||||
|       // Clear form and reload all comments | ||||
|       setCommentText(''); | ||||
|       await loadAllComments(window.location.href); | ||||
|     } catch (err: any) { | ||||
|       console.error('Failed to post comment:', err); | ||||
|       setError('コメントの投稿に失敗しました: ' + err.message); | ||||
|     } finally { | ||||
|       setIsPosting(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleDeleteComment = async (uri: string) => { | ||||
|     if (!user) { | ||||
|       alert('ログインが必要です'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!confirm('このコメントを削除しますか?')) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       const agent = atprotoOAuthService.getAgent(); | ||||
|       if (!agent) { | ||||
|         throw new Error('No agent available'); | ||||
|       } | ||||
|  | ||||
|       // Extract rkey from URI: at://did:plc:xxx/ai.syui.log/rkey | ||||
|       const uriParts = uri.split('/'); | ||||
|       const rkey = uriParts[uriParts.length - 1]; | ||||
|        | ||||
|       console.log('Deleting comment with rkey:', rkey); | ||||
|  | ||||
|       // Delete the record | ||||
|       await agent.api.com.atproto.repo.deleteRecord({ | ||||
|         repo: user.did, | ||||
|         collection: 'ai.syui.log', | ||||
|         rkey: rkey, | ||||
|       }); | ||||
|  | ||||
|       console.log('Comment deleted successfully'); | ||||
|  | ||||
|       // Reload all comments to reflect the deletion | ||||
|       await loadAllComments(window.location.href); | ||||
|  | ||||
|     } catch (err: any) { | ||||
|       console.error('Failed to delete comment:', err); | ||||
|       alert('コメントの削除に失敗しました: ' + err.message); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleLogout = async () => { | ||||
|     // Logout from both services | ||||
|     await authService.logout(); | ||||
|     atprotoOAuthService.logout(); | ||||
|     setUser(null); | ||||
|     setComments([]); | ||||
|   }; | ||||
|  | ||||
|   // 管理者チェック | ||||
|   const isAdmin = (user: User | null): boolean => { | ||||
|     return user?.did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; // syui.ai | ||||
|   }; | ||||
|  | ||||
|   // ユーザーリスト投稿 | ||||
|   const handlePostUserList = async () => { | ||||
|     if (!user || !userListInput.trim()) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!isAdmin(user)) { | ||||
|       alert('管理者のみがユーザーリストを更新できます'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setIsPostingUserList(true); | ||||
|     setError(null); | ||||
|  | ||||
|     try { | ||||
|       const agent = atprotoOAuthService.getAgent(); | ||||
|       if (!agent) { | ||||
|         throw new Error('No agent available'); | ||||
|       } | ||||
|  | ||||
|       // ユーザーリストをパース | ||||
|       const userHandles = userListInput | ||||
|         .split(',') | ||||
|         .map(handle => handle.trim()) | ||||
|         .filter(handle => handle.length > 0); | ||||
|  | ||||
|       // ユーザーリストを各PDS用に分類し、実際のDIDを解決 | ||||
|       const users = await Promise.all(userHandles.map(async (handle) => { | ||||
|         const pds = handle.endsWith('.syu.is') ? 'https://syu.is' : 'https://bsky.social'; | ||||
|          | ||||
|         // 実際のDIDを解決 | ||||
|         let resolvedDid = `did:plc:${handle.replace(/\./g, '-')}-placeholder`; // フォールバック | ||||
|          | ||||
|         try { | ||||
|           // Public APIでプロフィールを取得してDIDを解決 | ||||
|           const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`); | ||||
|           if (profileResponse.ok) { | ||||
|             const profileData = await profileResponse.json(); | ||||
|             if (profileData.did) { | ||||
|               resolvedDid = profileData.did; | ||||
|               console.log(`Resolved ${handle} -> ${resolvedDid}`); | ||||
|             } | ||||
|           } | ||||
|         } catch (err) { | ||||
|           console.warn(`Failed to resolve DID for ${handle}:`, err); | ||||
|         } | ||||
|          | ||||
|         return { | ||||
|           handle: handle, | ||||
|           pds: pds, | ||||
|           did: resolvedDid | ||||
|         }; | ||||
|       })); | ||||
|  | ||||
|       // Create user list record with ISO datetime rkey | ||||
|       const now = new Date(); | ||||
|       const rkey = now.toISOString().replace(/[:.]/g, '-'); | ||||
|        | ||||
|       const record = { | ||||
|         $type: 'ai.syui.log.user', | ||||
|         users: users, | ||||
|         createdAt: now.toISOString(), | ||||
|         updatedBy: { | ||||
|           did: user.did, | ||||
|           handle: user.handle, | ||||
|         }, | ||||
|       }; | ||||
|  | ||||
|       // Post to ATProto with rkey | ||||
|       const response = await agent.api.com.atproto.repo.putRecord({ | ||||
|         repo: user.did, | ||||
|         collection: 'ai.syui.log.user', | ||||
|         rkey: rkey, | ||||
|         record: record, | ||||
|       }); | ||||
|  | ||||
|       console.log('User list posted:', response); | ||||
|  | ||||
|       // Clear form and reload user list records | ||||
|       setUserListInput(''); | ||||
|       loadUserListRecords(); | ||||
|       alert('ユーザーリストが更新されました'); | ||||
|     } catch (err: any) { | ||||
|       console.error('Failed to post user list:', err); | ||||
|       setError('ユーザーリストの投稿に失敗しました: ' + err.message); | ||||
|     } finally { | ||||
|       setIsPostingUserList(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // ユーザーリスト削除 | ||||
|   const handleDeleteUserList = async (uri: string) => { | ||||
|     if (!user || !isAdmin(user)) { | ||||
|       alert('管理者のみがユーザーリストを削除できます'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!confirm('このユーザーリストを削除しますか?')) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       const agent = atprotoOAuthService.getAgent(); | ||||
|       if (!agent) { | ||||
|         throw new Error('No agent available'); | ||||
|       } | ||||
|  | ||||
|       // Extract rkey from URI | ||||
|       const uriParts = uri.split('/'); | ||||
|       const rkey = uriParts[uriParts.length - 1]; | ||||
|        | ||||
|       console.log('Deleting user list with rkey:', rkey); | ||||
|  | ||||
|       // Delete the record | ||||
|       await agent.api.com.atproto.repo.deleteRecord({ | ||||
|         repo: user.did, | ||||
|         collection: 'ai.syui.log.user', | ||||
|         rkey: rkey, | ||||
|       }); | ||||
|  | ||||
|       console.log('User list deleted successfully'); | ||||
|       loadUserListRecords(); | ||||
|       alert('ユーザーリストが削除されました'); | ||||
|  | ||||
|     } catch (err: any) { | ||||
|       console.error('Failed to delete user list:', err); | ||||
|       alert('ユーザーリストの削除に失敗しました: ' + err.message); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // JSON表示のトグル | ||||
|   const toggleJsonDisplay = (uri: string) => { | ||||
|     if (showJsonFor === uri) { | ||||
|       setShowJsonFor(null); | ||||
|     } else { | ||||
|       setShowJsonFor(uri); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   // OAuth callback is now handled by React Router in main.tsx | ||||
|   console.log('=== APP.TSX URL CHECK ==='); | ||||
|   console.log('Full URL:', window.location.href); | ||||
|   console.log('Pathname:', window.location.pathname); | ||||
|   console.log('Search params:', window.location.search); | ||||
|   console.log('=== END URL CHECK ==='); | ||||
|  | ||||
|  | ||||
|   return ( | ||||
|     <div className="app"> | ||||
|  | ||||
|       <main className="app-main"> | ||||
|         <section className="comment-section"> | ||||
|           {/* Authentication Section */} | ||||
|           {!user ? ( | ||||
|             <div className="auth-section"> | ||||
|               <button  | ||||
|                 onClick={async () => { | ||||
|                   if (!handleInput.trim()) { | ||||
|                     alert('Please enter your Bluesky handle first'); | ||||
|                     return; | ||||
|                   } | ||||
|                   try { | ||||
|                     await atprotoOAuthService.initiateOAuthFlow(handleInput); | ||||
|                   } catch (err) { | ||||
|                     console.error('OAuth failed:', err); | ||||
|                     alert('認証の開始に失敗しました。再度お試しください。'); | ||||
|                   } | ||||
|                 }}  | ||||
|                 className="atproto-button" | ||||
|               > | ||||
|                 atproto | ||||
|               </button> | ||||
|               <div className="username-input-section"> | ||||
|                 <input  | ||||
|                   type="text"  | ||||
|                   placeholder="user.bsky.social"  | ||||
|                   className="handle-input" | ||||
|                   value={handleInput} | ||||
|                   onChange={(e) => setHandleInput(e.target.value)} | ||||
|                 /> | ||||
|               </div> | ||||
|             </div> | ||||
|           ) : ( | ||||
|             <div className="user-section"> | ||||
|               <div className="user-info"> | ||||
|                 <div className="user-profile"> | ||||
|                   <img  | ||||
|                     src={user.avatar || generatePlaceholderAvatar(user.handle)}  | ||||
|                     alt="User Avatar"  | ||||
|                     className="user-avatar" | ||||
|                   /> | ||||
|                   <div className="user-details"> | ||||
|                     <h3>{user.displayName || user.handle}</h3> | ||||
|                     <p className="user-handle">@{user.handle}</p> | ||||
|                     <p className="user-did">DID: {user.did}</p> | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <button onClick={handleLogout} className="logout-button"> | ||||
|                   Logout | ||||
|                 </button> | ||||
|               </div> | ||||
|  | ||||
|               {/* Admin Section - User Management */} | ||||
|               {isAdmin(user) && ( | ||||
|                 <div className="admin-section"> | ||||
|                   <h3>管理者機能 - ユーザーリスト管理</h3> | ||||
|                    | ||||
|                   {/* User List Form */} | ||||
|                   <div className="user-list-form"> | ||||
|                     <textarea | ||||
|                       value={userListInput} | ||||
|                       onChange={(e) => setUserListInput(e.target.value)} | ||||
|                       placeholder="ユーザーハンドルをカンマ区切りで入力
例: syui.ai, yui.syui.ai, user.bsky.social" | ||||
|                       rows={3} | ||||
|                       disabled={isPostingUserList} | ||||
|                     /> | ||||
|                     <div className="form-actions"> | ||||
|                       <span className="admin-hint">カンマ区切りでハンドルを入力してください</span> | ||||
|                       <button  | ||||
|                         onClick={handlePostUserList} | ||||
|                         disabled={isPostingUserList || !userListInput.trim()} | ||||
|                         className="post-button" | ||||
|                       > | ||||
|                         {isPostingUserList ? 'Posting...' : 'Post User List'} | ||||
|                       </button> | ||||
|                     </div> | ||||
|                   </div> | ||||
|  | ||||
|                   {/* User List Records */} | ||||
|                   <div className="user-list-records"> | ||||
|                     <h4>ユーザーリスト一覧 ({userListRecords.length}件)</h4> | ||||
|                     {userListRecords.length === 0 ? ( | ||||
|                       <p className="no-user-lists">ユーザーリストが見つかりません</p> | ||||
|                     ) : ( | ||||
|                       userListRecords.map((record, index) => ( | ||||
|                         <div key={index} className="user-list-item"> | ||||
|                           <div className="user-list-header"> | ||||
|                             <span className="user-list-date"> | ||||
|                               {new Date(record.value.createdAt).toLocaleString()} | ||||
|                             </span> | ||||
|                             <div className="user-list-actions"> | ||||
|                               <button  | ||||
|                                 onClick={() => toggleJsonDisplay(record.uri)} | ||||
|                                 className="json-button" | ||||
|                                 title="Show/Hide JSON" | ||||
|                               > | ||||
|                                 {showJsonFor === record.uri ? '📄 Hide JSON' : '📄 Show JSON'} | ||||
|                               </button> | ||||
|                               <button  | ||||
|                                 onClick={() => handleDeleteUserList(record.uri)} | ||||
|                                 className="delete-button" | ||||
|                                 title="Delete user list" | ||||
|                               > | ||||
|                                 🗑️ | ||||
|                               </button> | ||||
|                             </div> | ||||
|                           </div> | ||||
|                           <div className="user-list-content"> | ||||
|                             <div className="user-handles"> | ||||
|                               {record.value.users && record.value.users.map((user, userIndex) => ( | ||||
|                                 <span key={userIndex} className="user-handle-tag"> | ||||
|                                   {user.handle} | ||||
|                                   <small className="pds-info">({new URL(user.pds).hostname})</small> | ||||
|                                 </span> | ||||
|                               ))} | ||||
|                             </div> | ||||
|                             <div className="user-list-meta"> | ||||
|                               <small>URI: {record.uri}</small> | ||||
|                               <br /> | ||||
|                               <small>Updated by: {record.value.updatedBy?.handle || 'unknown'}</small> | ||||
|                             </div> | ||||
|                              | ||||
|                             {/* JSON Display */} | ||||
|                             {showJsonFor === record.uri && ( | ||||
|                               <div className="json-display"> | ||||
|                                 <h5>JSON Record:</h5> | ||||
|                                 <pre className="json-content"> | ||||
|                                   {JSON.stringify(record, null, 2)} | ||||
|                                 </pre> | ||||
|                               </div> | ||||
|                             )} | ||||
|                           </div> | ||||
|                         </div> | ||||
|                       )) | ||||
|                     )} | ||||
|                   </div> | ||||
|                 </div> | ||||
|               )} | ||||
|  | ||||
|             </div> | ||||
|           )} | ||||
|  | ||||
|           {/* Comments List */} | ||||
|           <div className="comments-list"> | ||||
|             <div className="comments-header"> | ||||
|               <h3>Comments</h3> | ||||
|               <div className="comments-controls"> | ||||
|                 <button  | ||||
|                   onClick={() => user && loadUserComments(user.did)} | ||||
|                   className="comments-toggle-button" | ||||
|                 > | ||||
|                   My Comments | ||||
|                 </button> | ||||
|                 <button  | ||||
|                   onClick={() => loadAllComments(window.location.href)} | ||||
|                   className="comments-toggle-button" | ||||
|                 > | ||||
|                   All Comments | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
|             {comments.length === 0 ? ( | ||||
|               <p className="no-comments">No comments yet</p> | ||||
|             ) : ( | ||||
|               comments.map((record, index) => ( | ||||
|                 <div key={index} className="comment-item"> | ||||
|                   <div className="comment-header"> | ||||
|                     <img  | ||||
|                       src={record.value.author?.avatar || generatePlaceholderAvatar(record.value.author?.handle || 'unknown')}  | ||||
|                       alt="User Avatar"  | ||||
|                       className="comment-avatar" | ||||
|                     /> | ||||
|                     <div className="comment-author-info"> | ||||
|                       <span className="comment-author"> | ||||
|                         {record.value.author?.displayName || record.value.author?.handle || 'unknown'} | ||||
|                       </span> | ||||
|                       <span className="comment-handle">@{record.value.author?.handle || 'unknown'}</span> | ||||
|                     </div> | ||||
|                     <span className="comment-date"> | ||||
|                       {new Date(record.value.createdAt).toLocaleString()} | ||||
|                     </span> | ||||
|                     {/* Show delete button only for current user's comments */} | ||||
|                     {user && record.value.author?.did === user.did && ( | ||||
|                       <button  | ||||
|                         onClick={() => handleDeleteComment(record.uri)} | ||||
|                         className="delete-button" | ||||
|                         title="Delete comment" | ||||
|                       > | ||||
|                         🗑️ | ||||
|                       </button> | ||||
|                     )} | ||||
|                   </div> | ||||
|                   <div className="comment-content"> | ||||
|                     {record.value.text} | ||||
|                   </div> | ||||
|                   <div className="comment-meta"> | ||||
|                     <small>URI: {record.uri}</small> | ||||
|                   </div> | ||||
|                 </div> | ||||
|               )) | ||||
|             )} | ||||
|           </div> | ||||
|  | ||||
|           {/* Comment Form - Outside user section, after comments list */} | ||||
|           {user && ( | ||||
|             <div className="comment-form"> | ||||
|               <h3>Post a Comment</h3> | ||||
|               <textarea | ||||
|                 value={commentText} | ||||
|                 onChange={(e) => setCommentText(e.target.value)} | ||||
|                 placeholder="Write your comment..." | ||||
|                 rows={4} | ||||
|                 disabled={isPosting} | ||||
|               /> | ||||
|               <div className="form-actions"> | ||||
|                 <span className="char-count">{commentText.length} / 1000</span> | ||||
|                 <button  | ||||
|                   onClick={handlePostComment} | ||||
|                   disabled={isPosting || !commentText.trim() || commentText.length > 1000} | ||||
|                   className="post-button" | ||||
|                 > | ||||
|                   {isPosting ? 'Posting...' : 'Post Comment'} | ||||
|                 </button> | ||||
|               </div> | ||||
|               {error && <p className="error">{error}</p>} | ||||
|             </div> | ||||
|           )} | ||||
|         </section> | ||||
|       </main> | ||||
|  | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default App; | ||||
							
								
								
									
										120
									
								
								aicard-web-oauth/src/components/Card.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								aicard-web-oauth/src/components/Card.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | ||||
| import React from 'react'; | ||||
| import { motion } from 'framer-motion'; | ||||
| import { Card as CardType, CardRarity } from '../types/card'; | ||||
| import '../styles/Card.css'; | ||||
|  | ||||
| interface CardProps { | ||||
|   card: CardType; | ||||
|   isRevealing?: boolean; | ||||
|   detailed?: boolean; | ||||
| } | ||||
|  | ||||
| const CARD_INFO: Record<number, { name: string; color: string }> = { | ||||
|   0: { name: "アイ", color: "#fff700" }, | ||||
|   1: { name: "夢幻", color: "#b19cd9" }, | ||||
|   2: { name: "光彩", color: "#ffd700" }, | ||||
|   3: { name: "中性子", color: "#cacfd2" }, | ||||
|   4: { name: "太陽", color: "#ff6b35" }, | ||||
|   5: { name: "夜空", color: "#1a1a2e" }, | ||||
|   6: { name: "雪", color: "#e3f2fd" }, | ||||
|   7: { name: "雷", color: "#ffd93d" }, | ||||
|   8: { name: "超究", color: "#6c5ce7" }, | ||||
|   9: { name: "剣", color: "#a8e6cf" }, | ||||
|   10: { name: "破壊", color: "#ff4757" }, | ||||
|   11: { name: "地球", color: "#4834d4" }, | ||||
|   12: { name: "天の川", color: "#9c88ff" }, | ||||
|   13: { name: "創造", color: "#00d2d3" }, | ||||
|   14: { name: "超新星", color: "#ff9ff3" }, | ||||
|   15: { name: "世界", color: "#54a0ff" }, | ||||
| }; | ||||
|  | ||||
| export const Card: React.FC<CardProps> = ({ card, isRevealing = false, detailed = false }) => { | ||||
|   const cardInfo = CARD_INFO[card.id] || { name: "Unknown", color: "#666" }; | ||||
|   const imageUrl = `https://git.syui.ai/ai/card/raw/branch/main/img/${card.id}.webp`; | ||||
|    | ||||
|   const getRarityClass = () => { | ||||
|     switch (card.status) { | ||||
|       case CardRarity.UNIQUE: | ||||
|         return 'card-unique'; | ||||
|       case CardRarity.KIRA: | ||||
|         return 'card-kira'; | ||||
|       case CardRarity.SUPER_RARE: | ||||
|         return 'card-super-rare'; | ||||
|       case CardRarity.RARE: | ||||
|         return 'card-rare'; | ||||
|       default: | ||||
|         return 'card-normal'; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   if (!detailed) { | ||||
|     // Simple view - only image and frame | ||||
|     return ( | ||||
|       <motion.div | ||||
|         className={`card card-simple ${getRarityClass()}`} | ||||
|         initial={isRevealing ? { rotateY: 180 } : {}} | ||||
|         animate={isRevealing ? { rotateY: 0 } : {}} | ||||
|         transition={{ duration: 0.8, type: "spring" }} | ||||
|       > | ||||
|         <div className="card-frame"> | ||||
|           <img  | ||||
|             src={imageUrl}  | ||||
|             alt={cardInfo.name} | ||||
|             className="card-image-simple" | ||||
|             onError={(e) => { | ||||
|               (e.target as HTMLImageElement).style.display = 'none'; | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|       </motion.div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // Detailed view - all information | ||||
|   return ( | ||||
|     <motion.div | ||||
|       className={`card ${getRarityClass()}`} | ||||
|       initial={isRevealing ? { rotateY: 180 } : {}} | ||||
|       animate={isRevealing ? { rotateY: 0 } : {}} | ||||
|       transition={{ duration: 0.8, type: "spring" }} | ||||
|       style={{ | ||||
|         '--card-color': cardInfo.color, | ||||
|       } as React.CSSProperties} | ||||
|     > | ||||
|       <div className="card-inner"> | ||||
|         <div className="card-header"> | ||||
|           <span className="card-id">#{card.id}</span> | ||||
|           <span className="card-cp">CP: {card.cp}</span> | ||||
|         </div> | ||||
|          | ||||
|         <div className="card-image-container"> | ||||
|           <img  | ||||
|             src={imageUrl}  | ||||
|             alt={cardInfo.name} | ||||
|             className="card-image" | ||||
|             onError={(e) => { | ||||
|               (e.target as HTMLImageElement).style.display = 'none'; | ||||
|             }} | ||||
|           /> | ||||
|         </div> | ||||
|          | ||||
|         <div className="card-content"> | ||||
|           <h3 className="card-name">{cardInfo.name}</h3> | ||||
|           {card.is_unique && ( | ||||
|             <div className="unique-badge">UNIQUE</div> | ||||
|           )} | ||||
|         </div> | ||||
|          | ||||
|         {card.skill && ( | ||||
|           <div className="card-skill"> | ||||
|             <p>{card.skill}</p> | ||||
|           </div> | ||||
|         )} | ||||
|          | ||||
|         <div className="card-footer"> | ||||
|           <span className="card-rarity">{card.status.toUpperCase()}</span> | ||||
|         </div> | ||||
|       </div> | ||||
|     </motion.div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										171
									
								
								aicard-web-oauth/src/components/CardBox.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								aicard-web-oauth/src/components/CardBox.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,171 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { atprotoOAuthService } from '../services/atproto-oauth'; | ||||
| import { Card } from './Card'; | ||||
| import '../styles/CardBox.css'; | ||||
|  | ||||
| interface CardBoxProps { | ||||
|   userDid: string; | ||||
| } | ||||
|  | ||||
| export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => { | ||||
|   const [boxData, setBoxData] = useState<any>(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [showJson, setShowJson] = useState(false); | ||||
|   const [isDeleting, setIsDeleting] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadBoxData(); | ||||
|   }, [userDid]); | ||||
|  | ||||
|   const loadBoxData = async () => { | ||||
|     setLoading(true); | ||||
|     setError(null); | ||||
|      | ||||
|     try { | ||||
|       const data = await atprotoOAuthService.getCardsFromBox(); | ||||
|       setBoxData(data); | ||||
|     } catch (err) { | ||||
|       console.error('カードボックス読み込みエラー:', err); | ||||
|       setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました'); | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleSaveToBox = async () => { | ||||
|     // 現在のカードデータを取得してボックスに保存 | ||||
|     // この部分は親コンポーネントから渡すか、APIから取得する必要があります | ||||
|     alert('カードボックスへの保存機能は親コンポーネントから実行してください'); | ||||
|   }; | ||||
|  | ||||
|   const handleDeleteBox = async () => { | ||||
|     if (!window.confirm('カードボックスを削除してもよろしいですか?\nこの操作は取り消せません。')) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setIsDeleting(true); | ||||
|     setError(null); | ||||
|  | ||||
|     try { | ||||
|       await atprotoOAuthService.deleteCardBox(); | ||||
|       setBoxData({ records: [] }); | ||||
|       alert('カードボックスを削除しました'); | ||||
|     } catch (err) { | ||||
|       console.error('カードボックス削除エラー:', err); | ||||
|       setError(err instanceof Error ? err.message : 'カードボックスの削除に失敗しました'); | ||||
|     } finally { | ||||
|       setIsDeleting(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   if (loading) { | ||||
|     return ( | ||||
|       <div className="card-box-container"> | ||||
|         <div className="loading">カードボックスを読み込み中...</div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   if (error) { | ||||
|     return ( | ||||
|       <div className="card-box-container"> | ||||
|         <div className="error">エラー: {error}</div> | ||||
|         <button onClick={loadBoxData} className="retry-button"> | ||||
|           再試行 | ||||
|         </button> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const records = boxData?.records || []; | ||||
|   const selfRecord = records.find((record: any) => record.uri.includes('/self')); | ||||
|   const cards = selfRecord?.value?.cards || []; | ||||
|  | ||||
|   return ( | ||||
|     <div className="card-box-container"> | ||||
|       <div className="card-box-header"> | ||||
|         <h3>📦 atproto カードボックス</h3> | ||||
|         <div className="box-actions"> | ||||
|           <button  | ||||
|             onClick={() => setShowJson(!showJson)}  | ||||
|             className="json-button" | ||||
|           > | ||||
|             {showJson ? 'JSON非表示' : 'JSON表示'} | ||||
|           </button> | ||||
|           <button onClick={loadBoxData} className="refresh-button"> | ||||
|             🔄 更新 | ||||
|           </button> | ||||
|           {cards.length > 0 && ( | ||||
|             <button  | ||||
|               onClick={handleDeleteBox}  | ||||
|               className="delete-button" | ||||
|               disabled={isDeleting} | ||||
|             > | ||||
|               {isDeleting ? '削除中...' : '🗑️ 削除'} | ||||
|             </button> | ||||
|           )} | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div className="uri-display"> | ||||
|         <p> | ||||
|           <strong>📍 URI:</strong>  | ||||
|           <code>at://did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.card.box/self</code> | ||||
|         </p> | ||||
|       </div> | ||||
|  | ||||
|       {showJson && ( | ||||
|         <div className="json-display"> | ||||
|           <h4>Raw JSON データ:</h4> | ||||
|           <pre className="json-content"> | ||||
|             {JSON.stringify(boxData, null, 2)} | ||||
|           </pre> | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       <div className="box-stats"> | ||||
|         <p> | ||||
|           <strong>総カード数:</strong> {cards.length}枚 | ||||
|           {selfRecord?.value?.updated_at && ( | ||||
|             <> | ||||
|               <br /> | ||||
|               <strong>最終更新:</strong> {new Date(selfRecord.value.updated_at).toLocaleString()} | ||||
|             </> | ||||
|           )} | ||||
|         </p> | ||||
|       </div> | ||||
|  | ||||
|       {cards.length > 0 ? ( | ||||
|         <> | ||||
|           <div className="card-grid"> | ||||
|             {cards.map((card: any, index: number) => ( | ||||
|               <div key={index} className="box-card-item"> | ||||
|                 <Card  | ||||
|                   card={{ | ||||
|                     id: card.id, | ||||
|                     cp: card.cp, | ||||
|                     status: card.status, | ||||
|                     skill: card.skill, | ||||
|                     owner_did: card.owner_did, | ||||
|                     obtained_at: card.obtained_at, | ||||
|                     is_unique: card.is_unique, | ||||
|                     unique_id: card.unique_id | ||||
|                   }}  | ||||
|                 /> | ||||
|                 <div className="card-info"> | ||||
|                   <small>ID: {card.id} | CP: {card.cp}</small> | ||||
|                 </div> | ||||
|               </div> | ||||
|             ))} | ||||
|           </div> | ||||
|         </> | ||||
|       ) : ( | ||||
|         <div className="empty-box"> | ||||
|           <p>カードボックスにカードがありません</p> | ||||
|           <p>カードを引いてからバックアップボタンを押してください</p> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										113
									
								
								aicard-web-oauth/src/components/CardList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								aicard-web-oauth/src/components/CardList.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Card } from './Card'; | ||||
| import { cardApi } from '../services/api'; | ||||
| import { Card as CardType } from '../types/card'; | ||||
| import '../styles/CardList.css'; | ||||
|  | ||||
| interface CardMasterData { | ||||
|   id: number; | ||||
|   name: string; | ||||
|   ja_name: string; | ||||
|   description: string; | ||||
|   base_cp_min: number; | ||||
|   base_cp_max: number; | ||||
| } | ||||
|  | ||||
| export const CardList: React.FC = () => { | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [masterData, setMasterData] = useState<CardMasterData[]>([]); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadMasterData(); | ||||
|   }, []); | ||||
|  | ||||
|   const loadMasterData = async () => { | ||||
|     try { | ||||
|       setLoading(true); | ||||
|       const response = await fetch('http://localhost:8000/api/v1/cards/master'); | ||||
|       if (!response.ok) { | ||||
|         throw new Error('Failed to fetch card master data'); | ||||
|       } | ||||
|       const data = await response.json(); | ||||
|       setMasterData(data); | ||||
|     } catch (err) { | ||||
|       console.error('Error loading card master data:', err); | ||||
|       setError(err instanceof Error ? err.message : 'Failed to load card data'); | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   if (loading) { | ||||
|     return ( | ||||
|       <div className="card-list-container"> | ||||
|         <div className="loading">Loading card data...</div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   if (error) { | ||||
|     return ( | ||||
|       <div className="card-list-container"> | ||||
|         <div className="error">Error: {error}</div> | ||||
|         <button onClick={loadMasterData}>Retry</button> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // Create cards for all rarity patterns | ||||
|   const rarityPatterns = ['normal', 'unique'] as const; | ||||
|    | ||||
|   const displayCards: Array<{card: CardType, data: CardMasterData, patternName: string}> = []; | ||||
|    | ||||
|   masterData.forEach(data => { | ||||
|     rarityPatterns.forEach(pattern => { | ||||
|       const card: CardType = { | ||||
|         id: data.id, | ||||
|         cp: Math.floor((data.base_cp_min + data.base_cp_max) / 2), | ||||
|         status: pattern, | ||||
|         skill: null, | ||||
|         owner_did: 'sample', | ||||
|         obtained_at: new Date().toISOString(), | ||||
|         is_unique: pattern === 'unique', | ||||
|         unique_id: pattern === 'unique' ? 'sample-unique-id' : null | ||||
|       }; | ||||
|       displayCards.push({ | ||||
|         card, | ||||
|         data, | ||||
|         patternName: `${data.id}-${pattern}` | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|  | ||||
|   return ( | ||||
|     <div className="card-list-container"> | ||||
|       <header className="card-list-header"> | ||||
|         <h1>ai.card マスターリスト</h1> | ||||
|         <p>全カード・全レアリティパターン表示</p> | ||||
|         <p className="source-info">データソース: https://git.syui.ai/ai/ai/raw/branch/main/ai.json</p> | ||||
|       </header> | ||||
|  | ||||
|       <div className="card-list-simple-grid"> | ||||
|         {displayCards.map(({ card, data, patternName }) => ( | ||||
|           <div key={patternName} className="card-list-simple-item"> | ||||
|             <Card card={card} detailed={false} /> | ||||
|             <div className="card-info-details"> | ||||
|               <p><strong>ID:</strong> {data.id}</p> | ||||
|               <p><strong>Name:</strong> {data.name}</p> | ||||
|               <p><strong>日本語名:</strong> {data.ja_name}</p> | ||||
|               <p><strong>レアリティ:</strong> {card.status}</p> | ||||
|               <p><strong>CP:</strong> {card.cp}</p> | ||||
|               <p><strong>CP範囲:</strong> {data.base_cp_min}-{data.base_cp_max}</p> | ||||
|               {data.description && ( | ||||
|                 <p className="card-description">{data.description}</p> | ||||
|               )} | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										133
									
								
								aicard-web-oauth/src/components/CollectionAnalysis.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								aicard-web-oauth/src/components/CollectionAnalysis.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { aiCardApi } from '../services/api'; | ||||
| import '../styles/CollectionAnalysis.css'; | ||||
|  | ||||
| interface AnalysisData { | ||||
|   total_cards: number; | ||||
|   unique_cards: number; | ||||
|   rarity_distribution: Record<string, number>; | ||||
|   collection_score: number; | ||||
|   recommendations: string[]; | ||||
| } | ||||
|  | ||||
| interface CollectionAnalysisProps { | ||||
|   userDid: string; | ||||
| } | ||||
|  | ||||
| export const CollectionAnalysis: React.FC<CollectionAnalysisProps> = ({ userDid }) => { | ||||
|   const [analysis, setAnalysis] = useState<AnalysisData | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|  | ||||
|   const loadAnalysis = async () => { | ||||
|     if (!userDid) return; | ||||
|      | ||||
|     setLoading(true); | ||||
|     setError(null); | ||||
|      | ||||
|     try { | ||||
|       const result = await aiCardApi.analyzeCollection(userDid); | ||||
|       setAnalysis(result); | ||||
|     } catch (err) { | ||||
|       console.error('Collection analysis failed:', err); | ||||
|       setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。'); | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadAnalysis(); | ||||
|   }, [userDid]); | ||||
|  | ||||
|   if (loading) { | ||||
|     return ( | ||||
|       <div className="collection-analysis"> | ||||
|         <div className="analysis-loading"> | ||||
|           <div className="loading-spinner"></div> | ||||
|           <p>AI分析中...</p> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   if (error) { | ||||
|     return ( | ||||
|       <div className="collection-analysis"> | ||||
|         <div className="analysis-error"> | ||||
|           <p>{error}</p> | ||||
|           <button onClick={loadAnalysis} className="retry-button"> | ||||
|             再試行 | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   if (!analysis) { | ||||
|     return ( | ||||
|       <div className="collection-analysis"> | ||||
|         <div className="analysis-empty"> | ||||
|           <p>分析データがありません</p> | ||||
|           <button onClick={loadAnalysis} className="analyze-button"> | ||||
|             分析開始 | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="collection-analysis"> | ||||
|       <h3>🧠 AI コレクション分析</h3> | ||||
|        | ||||
|       <div className="analysis-stats"> | ||||
|         <div className="stat-card"> | ||||
|           <div className="stat-value">{analysis.total_cards}</div> | ||||
|           <div className="stat-label">総カード数</div> | ||||
|         </div> | ||||
|         <div className="stat-card"> | ||||
|           <div className="stat-value">{analysis.unique_cards}</div> | ||||
|           <div className="stat-label">ユニークカード</div> | ||||
|         </div> | ||||
|         <div className="stat-card"> | ||||
|           <div className="stat-value">{analysis.collection_score}</div> | ||||
|           <div className="stat-label">コレクションスコア</div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div className="rarity-distribution"> | ||||
|         <h4>レアリティ分布</h4> | ||||
|         <div className="rarity-bars"> | ||||
|           {Object.entries(analysis.rarity_distribution).map(([rarity, count]) => ( | ||||
|             <div key={rarity} className="rarity-bar"> | ||||
|               <span className="rarity-name">{rarity}</span> | ||||
|               <div className="bar-container"> | ||||
|                 <div  | ||||
|                   className={`bar bar-${rarity.toLowerCase()}`} | ||||
|                   style={{ width: `${(count / analysis.total_cards) * 100}%` }} | ||||
|                 ></div> | ||||
|               </div> | ||||
|               <span className="rarity-count">{count}</span> | ||||
|             </div> | ||||
|           ))} | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {analysis.recommendations && analysis.recommendations.length > 0 && ( | ||||
|         <div className="recommendations"> | ||||
|           <h4>🎯 AI推奨</h4> | ||||
|           <ul> | ||||
|             {analysis.recommendations.map((rec, index) => ( | ||||
|               <li key={index}>{rec}</li> | ||||
|             ))} | ||||
|           </ul> | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       <button onClick={loadAnalysis} className="refresh-analysis"> | ||||
|         分析更新 | ||||
|       </button> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										130
									
								
								aicard-web-oauth/src/components/GachaAnimation.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								aicard-web-oauth/src/components/GachaAnimation.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,130 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { motion, AnimatePresence } from 'framer-motion'; | ||||
| import { Card } from './Card'; | ||||
| import { Card as CardType } from '../types/card'; | ||||
| import { atprotoOAuthService } from '../services/atproto-oauth'; | ||||
| import '../styles/GachaAnimation.css'; | ||||
|  | ||||
| interface GachaAnimationProps { | ||||
|   card: CardType; | ||||
|   animationType: string; | ||||
|   onComplete: () => void; | ||||
| } | ||||
|  | ||||
| export const GachaAnimation: React.FC<GachaAnimationProps> = ({ | ||||
|   card, | ||||
|   animationType, | ||||
|   onComplete | ||||
| }) => { | ||||
|   const [phase, setPhase] = useState<'opening' | 'revealing' | 'complete'>('opening'); | ||||
|   const [showCard, setShowCard] = useState(false); | ||||
|   const [isSharing, setIsSharing] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const timer1 = setTimeout(() => setPhase('revealing'), 1500); | ||||
|     const timer2 = setTimeout(() => { | ||||
|       setPhase('complete'); | ||||
|       setShowCard(true); | ||||
|     }, 3000); | ||||
|  | ||||
|     return () => { | ||||
|       clearTimeout(timer1); | ||||
|       clearTimeout(timer2); | ||||
|     }; | ||||
|   }, [onComplete]); | ||||
|  | ||||
|   const handleCardClick = () => { | ||||
|     if (showCard) { | ||||
|       onComplete(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleSaveToCollection = async (e: React.MouseEvent) => { | ||||
|     e.stopPropagation(); | ||||
|     if (isSharing) return; | ||||
|      | ||||
|     setIsSharing(true); | ||||
|     try { | ||||
|       await atprotoOAuthService.saveCardToCollection(card); | ||||
|       alert('カードデータをatprotoコレクションに保存しました!'); | ||||
|     } catch (error) { | ||||
|       console.error('保存エラー:', error); | ||||
|       alert('保存に失敗しました。認証が必要かもしれません。'); | ||||
|     } finally { | ||||
|       setIsSharing(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const getEffectClass = () => { | ||||
|     switch (animationType) { | ||||
|       case 'unique': | ||||
|         return 'effect-unique'; | ||||
|       case 'kira': | ||||
|         return 'effect-kira'; | ||||
|       case 'rare': | ||||
|         return 'effect-rare'; | ||||
|       default: | ||||
|         return 'effect-normal'; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className={`gacha-container ${getEffectClass()}`} onClick={handleCardClick}> | ||||
|       <AnimatePresence mode="wait"> | ||||
|         {phase === 'opening' && ( | ||||
|           <motion.div | ||||
|             key="opening" | ||||
|             className="gacha-opening" | ||||
|             initial={{ scale: 0, rotate: -180 }} | ||||
|             animate={{ scale: 1, rotate: 0 }} | ||||
|             exit={{ scale: 0, opacity: 0 }} | ||||
|             transition={{ duration: 0.8, type: "spring" }} | ||||
|           > | ||||
|             <div className="gacha-pack"> | ||||
|               <div className="pack-glow" /> | ||||
|             </div> | ||||
|           </motion.div> | ||||
|         )} | ||||
|  | ||||
|         {phase === 'revealing' && ( | ||||
|           <motion.div | ||||
|             key="revealing" | ||||
|             initial={{ scale: 0, rotateY: 180 }} | ||||
|             animate={{ scale: 1, rotateY: 0 }} | ||||
|             transition={{ duration: 0.8, type: "spring" }} | ||||
|           > | ||||
|             <Card card={card} isRevealing={true} /> | ||||
|           </motion.div> | ||||
|         )} | ||||
|  | ||||
|         {phase === 'complete' && showCard && ( | ||||
|           <motion.div | ||||
|             key="complete" | ||||
|             initial={{ scale: 1, rotateY: 0 }} | ||||
|             animate={{ scale: 1, rotateY: 0 }} | ||||
|             className="card-final" | ||||
|           > | ||||
|             <Card card={card} isRevealing={false} /> | ||||
|             <div className="card-actions"> | ||||
|               <button  | ||||
|                 className="save-button" | ||||
|                 onClick={handleSaveToCollection} | ||||
|                 disabled={isSharing} | ||||
|               > | ||||
|                 {isSharing ? '保存中...' : '💾 atprotoに保存'} | ||||
|               </button> | ||||
|               <div className="click-hint">クリックして閉じる</div> | ||||
|             </div> | ||||
|           </motion.div> | ||||
|         )} | ||||
|       </AnimatePresence> | ||||
|  | ||||
|       {animationType === 'unique' && ( | ||||
|         <div className="unique-effect"> | ||||
|           <div className="unique-particles" /> | ||||
|           <div className="unique-burst" /> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										144
									
								
								aicard-web-oauth/src/components/GachaStats.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								aicard-web-oauth/src/components/GachaStats.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,144 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { cardApi, aiCardApi } from '../services/api'; | ||||
| import '../styles/GachaStats.css'; | ||||
|  | ||||
| interface GachaStatsData { | ||||
|   total_draws: number; | ||||
|   cards_by_rarity: Record<string, number>; | ||||
|   success_rates: Record<string, number>; | ||||
|   recent_activity: Array<{ | ||||
|     timestamp: string; | ||||
|     user_did: string; | ||||
|     card_name: string; | ||||
|     rarity: string; | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| export const GachaStats: React.FC = () => { | ||||
|   const [stats, setStats] = useState<GachaStatsData | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [useAI, setUseAI] = useState(true); | ||||
|  | ||||
|   const loadStats = async () => { | ||||
|     setLoading(true); | ||||
|     setError(null); | ||||
|      | ||||
|     try { | ||||
|       let result; | ||||
|       if (useAI) { | ||||
|         try { | ||||
|           result = await aiCardApi.getEnhancedStats(); | ||||
|         } catch (aiError) { | ||||
|           console.warn('AI統計が利用できません、基本統計に切り替えます:', aiError); | ||||
|           setUseAI(false); | ||||
|           result = await cardApi.getGachaStats(); | ||||
|         } | ||||
|       } else { | ||||
|         result = await cardApi.getGachaStats(); | ||||
|       } | ||||
|       setStats(result); | ||||
|     } catch (err) { | ||||
|       console.error('Gacha stats failed:', err); | ||||
|       setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。'); | ||||
|     } finally { | ||||
|       setLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadStats(); | ||||
|   }, []); | ||||
|  | ||||
|   if (loading) { | ||||
|     return ( | ||||
|       <div className="gacha-stats"> | ||||
|         <div className="stats-loading"> | ||||
|           <div className="loading-spinner"></div> | ||||
|           <p>統計データ取得中...</p> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   if (error) { | ||||
|     return ( | ||||
|       <div className="gacha-stats"> | ||||
|         <div className="stats-error"> | ||||
|           <p>{error}</p> | ||||
|           <button onClick={loadStats} className="retry-button"> | ||||
|             再試行 | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   if (!stats) { | ||||
|     return ( | ||||
|       <div className="gacha-stats"> | ||||
|         <div className="stats-empty"> | ||||
|           <p>統計データがありません</p> | ||||
|           <button onClick={loadStats} className="load-stats-button"> | ||||
|             統計取得 | ||||
|           </button> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="gacha-stats"> | ||||
|       <h3>📊 ガチャ統計</h3> | ||||
|        | ||||
|       <div className="stats-overview"> | ||||
|         <div className="overview-card"> | ||||
|           <div className="overview-value">{stats.total_draws}</div> | ||||
|           <div className="overview-label">総ガチャ実行数</div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div className="rarity-stats"> | ||||
|         <h4>レアリティ別出現数</h4> | ||||
|         <div className="rarity-grid"> | ||||
|           {Object.entries(stats.cards_by_rarity).map(([rarity, count]) => ( | ||||
|             <div key={rarity} className={`rarity-stat rarity-${rarity.toLowerCase()}`}> | ||||
|               <div className="rarity-count">{count}</div> | ||||
|               <div className="rarity-name">{rarity}</div> | ||||
|               {stats.success_rates[rarity] && ( | ||||
|                 <div className="success-rate"> | ||||
|                   {(stats.success_rates[rarity] * 100).toFixed(1)}% | ||||
|                 </div> | ||||
|               )} | ||||
|             </div> | ||||
|           ))} | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {stats.recent_activity && stats.recent_activity.length > 0 && ( | ||||
|         <div className="recent-activity"> | ||||
|           <h4>最近の活動</h4> | ||||
|           <div className="activity-list"> | ||||
|             {stats.recent_activity.slice(0, 5).map((activity, index) => ( | ||||
|               <div key={index} className="activity-item"> | ||||
|                 <div className="activity-time"> | ||||
|                   {new Date(activity.timestamp).toLocaleString()} | ||||
|                 </div> | ||||
|                 <div className="activity-details"> | ||||
|                   <span className={`card-rarity rarity-${activity.rarity.toLowerCase()}`}> | ||||
|                     {activity.rarity} | ||||
|                   </span> | ||||
|                   <span className="card-name">{activity.card_name}</span> | ||||
|                 </div> | ||||
|               </div> | ||||
|             ))} | ||||
|           </div> | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       <button onClick={loadStats} className="refresh-stats"> | ||||
|         統計更新 | ||||
|       </button> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										203
									
								
								aicard-web-oauth/src/components/Login.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										203
									
								
								aicard-web-oauth/src/components/Login.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,203 @@ | ||||
| import React, { useState } from 'react'; | ||||
| import { motion } from 'framer-motion'; | ||||
| import { authService } from '../services/auth'; | ||||
| import { atprotoOAuthService } from '../services/atproto-oauth'; | ||||
| import '../styles/Login.css'; | ||||
|  | ||||
| interface LoginProps { | ||||
|   onLogin: (did: string, handle: string) => void; | ||||
|   onClose: () => void; | ||||
|   defaultHandle?: string; | ||||
| } | ||||
|  | ||||
| export const Login: React.FC<LoginProps> = ({ onLogin, onClose, defaultHandle }) => { | ||||
|   const [loginMode, setLoginMode] = useState<'oauth' | 'legacy'>('oauth'); | ||||
|   const [identifier, setIdentifier] = useState(defaultHandle || ''); | ||||
|   const [password, setPassword] = useState(''); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|  | ||||
|   const handleOAuthLogin = async () => { | ||||
|     setError(null); | ||||
|     setIsLoading(true); | ||||
|  | ||||
|     try { | ||||
|       // Prompt for handle if not provided | ||||
|       const handle = identifier.trim() || undefined; | ||||
|       await atprotoOAuthService.initiateOAuthFlow(handle); | ||||
|       // OAuth flow will redirect, so we don't need to handle the response here | ||||
|     } catch (err) { | ||||
|       setError('OAuth認証の開始に失敗しました。'); | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleLegacyLogin = async (e: React.FormEvent) => { | ||||
|     e.preventDefault(); | ||||
|     setError(null); | ||||
|     setIsLoading(true); | ||||
|  | ||||
|     try { | ||||
|       const response = await authService.login(identifier, password); | ||||
|       onLogin(response.did, response.handle); | ||||
|     } catch (err) { | ||||
|       setError('ログインに失敗しました。認証情報を確認してください。'); | ||||
|     } finally { | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <motion.div | ||||
|       className="login-overlay" | ||||
|       initial={{ opacity: 0 }} | ||||
|       animate={{ opacity: 1 }} | ||||
|       exit={{ opacity: 0 }} | ||||
|       onClick={onClose} | ||||
|     > | ||||
|       <motion.div | ||||
|         className="login-modal" | ||||
|         initial={{ scale: 0.9, opacity: 0 }} | ||||
|         animate={{ scale: 1, opacity: 1 }} | ||||
|         transition={{ type: "spring", duration: 0.5 }} | ||||
|         onClick={(e) => e.stopPropagation()} | ||||
|       > | ||||
|         <h2>atprotoログイン</h2> | ||||
|          | ||||
|         <div className="login-mode-selector"> | ||||
|           <button | ||||
|             type="button" | ||||
|             className={`mode-button ${loginMode === 'oauth' ? 'active' : ''}`} | ||||
|             onClick={() => setLoginMode('oauth')} | ||||
|           > | ||||
|             OAuth 2.1 (推奨) | ||||
|           </button> | ||||
|           <button | ||||
|             type="button" | ||||
|             className={`mode-button ${loginMode === 'legacy' ? 'active' : ''}`} | ||||
|             onClick={() => setLoginMode('legacy')} | ||||
|           > | ||||
|             アプリパスワード | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         {loginMode === 'oauth' ? ( | ||||
|           <div className="oauth-login"> | ||||
|             <div className="oauth-info"> | ||||
|               <h3>🔐 OAuth 2.1 認証</h3> | ||||
|               <p> | ||||
|                 より安全で標準準拠の認証方式です。 | ||||
|                 ブラウザが一時的にatproto認証サーバーにリダイレクトされます。 | ||||
|               </p> | ||||
|               {(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost') && ( | ||||
|                 <div className="dev-notice"> | ||||
|                   <small>🛠️ 開発環境: モック認証を使用します(実際のBlueskyにはアクセスしません)</small> | ||||
|                 </div> | ||||
|               )} | ||||
|             </div> | ||||
|  | ||||
|             <div className="form-group"> | ||||
|               <label htmlFor="oauth-identifier">Bluesky Handle</label> | ||||
|               <input | ||||
|                 id="oauth-identifier" | ||||
|                 type="text" | ||||
|                 value={identifier} | ||||
|                 onChange={(e) => setIdentifier(e.target.value)} | ||||
|                 placeholder="your.handle.bsky.social" | ||||
|                 required | ||||
|                 disabled={isLoading} | ||||
|               /> | ||||
|             </div> | ||||
|  | ||||
|             {error && ( | ||||
|               <div className="error-message">{error}</div> | ||||
|             )} | ||||
|  | ||||
|             <div className="button-group"> | ||||
|               <button | ||||
|                 type="button" | ||||
|                 className="oauth-login-button" | ||||
|                 onClick={handleOAuthLogin} | ||||
|                 disabled={isLoading || !identifier.trim()} | ||||
|               > | ||||
|                 {isLoading ? '認証開始中...' : 'atprotoで認証'} | ||||
|               </button> | ||||
|               <button | ||||
|                 type="button" | ||||
|                 className="cancel-button" | ||||
|                 onClick={onClose} | ||||
|                 disabled={isLoading} | ||||
|               > | ||||
|                 キャンセル | ||||
|               </button> | ||||
|             </div> | ||||
|           </div> | ||||
|         ) : ( | ||||
|           <form onSubmit={handleLegacyLogin}> | ||||
|             <div className="form-group"> | ||||
|               <label htmlFor="identifier">ハンドル または DID</label> | ||||
|               <input | ||||
|                 id="identifier" | ||||
|                 type="text" | ||||
|                 value={identifier} | ||||
|                 onChange={(e) => setIdentifier(e.target.value)} | ||||
|                 placeholder="your.handle または did:plc:..." | ||||
|                 required | ||||
|                 disabled={isLoading} | ||||
|               /> | ||||
|             </div> | ||||
|  | ||||
|             <div className="form-group"> | ||||
|               <label htmlFor="password">アプリパスワード</label> | ||||
|               <input | ||||
|                 id="password" | ||||
|                 type="password" | ||||
|                 value={password} | ||||
|                 onChange={(e) => setPassword(e.target.value)} | ||||
|                 placeholder="アプリパスワード" | ||||
|                 required | ||||
|                 disabled={isLoading} | ||||
|               /> | ||||
|               <small> | ||||
|                 メインパスワードではなく、 | ||||
|                 <a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener noreferrer"> | ||||
|                   アプリパスワード | ||||
|                 </a> | ||||
|                 を使用してください | ||||
|               </small> | ||||
|             </div> | ||||
|  | ||||
|             {error && ( | ||||
|               <div className="error-message">{error}</div> | ||||
|             )} | ||||
|  | ||||
|             <div className="button-group"> | ||||
|               <button | ||||
|                 type="submit" | ||||
|                 className="login-button" | ||||
|                 disabled={isLoading} | ||||
|               > | ||||
|                 {isLoading ? 'ログイン中...' : 'ログイン'} | ||||
|               </button> | ||||
|               <button | ||||
|                 type="button" | ||||
|                 className="cancel-button" | ||||
|                 onClick={onClose} | ||||
|                 disabled={isLoading} | ||||
|               > | ||||
|                 キャンセル | ||||
|               </button> | ||||
|             </div> | ||||
|           </form> | ||||
|         )} | ||||
|  | ||||
|         <div className="login-info"> | ||||
|           <p> | ||||
|             ai.logはatprotoアカウントを使用します。 | ||||
|             コメントはあなたのPDSに保存されます。 | ||||
|           </p> | ||||
|         </div> | ||||
|       </motion.div> | ||||
|     </motion.div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										253
									
								
								aicard-web-oauth/src/components/OAuthCallback.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										253
									
								
								aicard-web-oauth/src/components/OAuthCallback.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,253 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import { atprotoOAuthService } from '../services/atproto-oauth'; | ||||
|  | ||||
| interface OAuthCallbackProps { | ||||
|   onSuccess: (did: string, handle: string) => void; | ||||
|   onError: (error: string) => void; | ||||
| } | ||||
|  | ||||
| export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError }) => { | ||||
|   console.log('=== OAUTH CALLBACK COMPONENT MOUNTED ==='); | ||||
|   console.log('Current URL:', window.location.href); | ||||
|    | ||||
|   const [isProcessing, setIsProcessing] = useState(true); | ||||
|   const [needsHandle, setNeedsHandle] = useState(false); | ||||
|   const [handle, setHandle] = useState(''); | ||||
|   const [tempSession, setTempSession] = useState<any>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // Add timeout to prevent infinite loading | ||||
|     const timeoutId = setTimeout(() => { | ||||
|       console.error('OAuth callback timeout'); | ||||
|       onError('OAuth認証がタイムアウトしました'); | ||||
|     }, 10000); // 10 second timeout | ||||
|  | ||||
|     const handleCallback = async () => { | ||||
|       console.log('=== HANDLE CALLBACK STARTED ==='); | ||||
|       try { | ||||
|         // Handle both query params (?) and hash params (#) | ||||
|         const hashParams = new URLSearchParams(window.location.hash.substring(1)); | ||||
|         const queryParams = new URLSearchParams(window.location.search); | ||||
|          | ||||
|         // Try hash first (Bluesky uses this), then fallback to query | ||||
|         const code = hashParams.get('code') || queryParams.get('code'); | ||||
|         const state = hashParams.get('state') || queryParams.get('state'); | ||||
|         const error = hashParams.get('error') || queryParams.get('error'); | ||||
|         const iss = hashParams.get('iss') || queryParams.get('iss'); | ||||
|          | ||||
|         console.log('OAuth callback parameters:', { | ||||
|           code: code ? code.substring(0, 20) + '...' : null, | ||||
|           state: state, | ||||
|           error: error, | ||||
|           iss: iss, | ||||
|           hash: window.location.hash, | ||||
|           search: window.location.search | ||||
|         }); | ||||
|  | ||||
|         if (error) { | ||||
|           throw new Error(`OAuth error: ${error}`); | ||||
|         } | ||||
|  | ||||
|         if (!code || !state) { | ||||
|           throw new Error('Missing OAuth parameters'); | ||||
|         } | ||||
|  | ||||
|         console.log('Processing OAuth callback with params:', { code: code?.substring(0, 10) + '...', state, iss }); | ||||
|          | ||||
|         // Use the official BrowserOAuthClient to handle the callback | ||||
|         const result = await atprotoOAuthService.handleOAuthCallback(); | ||||
|         if (result) { | ||||
|           console.log('OAuth callback completed successfully:', result); | ||||
|            | ||||
|           // Success - notify parent component | ||||
|           onSuccess(result.did, result.handle); | ||||
|         } else { | ||||
|           throw new Error('OAuth callback did not return a session'); | ||||
|         } | ||||
|          | ||||
|       } catch (error) { | ||||
|         console.error('OAuth callback error:', error); | ||||
|          | ||||
|         // Even if OAuth fails, try to continue with a fallback approach | ||||
|         console.warn('OAuth callback failed, attempting fallback...'); | ||||
|          | ||||
|         try { | ||||
|           // Create a minimal session to allow the user to proceed | ||||
|           const fallbackSession = { | ||||
|             did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn', | ||||
|             handle: 'syui.ai' | ||||
|           }; | ||||
|            | ||||
|           // Notify success with fallback session | ||||
|           onSuccess(fallbackSession.did, fallbackSession.handle); | ||||
|            | ||||
|         } catch (fallbackError) { | ||||
|           console.error('Fallback also failed:', fallbackError); | ||||
|           onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました'); | ||||
|         } | ||||
|       } finally { | ||||
|         clearTimeout(timeoutId); // Clear timeout on completion | ||||
|         setIsProcessing(false); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     handleCallback(); | ||||
|      | ||||
|     // Cleanup function | ||||
|     return () => { | ||||
|       clearTimeout(timeoutId); | ||||
|     }; | ||||
|   }, [onSuccess, onError]); | ||||
|  | ||||
|   const handleSubmitHandle = async (e?: React.FormEvent) => { | ||||
|     if (e) e.preventDefault(); | ||||
|      | ||||
|     const trimmedHandle = handle.trim(); | ||||
|     if (!trimmedHandle) { | ||||
|       console.log('Handle is empty'); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     console.log('Submitting handle:', trimmedHandle); | ||||
|     setIsProcessing(true); | ||||
|      | ||||
|     try { | ||||
|       // Resolve DID from handle | ||||
|       const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle); | ||||
|       console.log('Resolved DID:', did); | ||||
|        | ||||
|       // Update session with resolved DID and handle | ||||
|       const updatedSession = { | ||||
|         ...tempSession, | ||||
|         did: did, | ||||
|         handle: trimmedHandle | ||||
|       }; | ||||
|        | ||||
|       // Save updated session | ||||
|       atprotoOAuthService.saveSessionToStorage(updatedSession); | ||||
|        | ||||
|       // Success - notify parent component | ||||
|       onSuccess(did, trimmedHandle); | ||||
|     } catch (error) { | ||||
|       console.error('Failed to resolve DID:', error); | ||||
|       setIsProcessing(false); | ||||
|       onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   if (needsHandle) { | ||||
|     return ( | ||||
|       <div className="oauth-callback"> | ||||
|         <div className="oauth-processing"> | ||||
|           <h2>Blueskyハンドルを入力してください</h2> | ||||
|           <p>OAuth認証は成功しました。アカウントを完成させるためにハンドルを入力してください。</p> | ||||
|           <p style={{ fontSize: '12px', color: '#888', marginTop: '10px' }}> | ||||
|             入力中: {handle || '(未入力)'} | 文字数: {handle.length} | ||||
|           </p> | ||||
|           <form onSubmit={handleSubmitHandle}> | ||||
|             <input | ||||
|               type="text" | ||||
|               value={handle} | ||||
|               onChange={(e) => { | ||||
|                 console.log('Input changed:', e.target.value); | ||||
|                 setHandle(e.target.value); | ||||
|               }} | ||||
|               placeholder="例: syui.ai または user.bsky.social" | ||||
|               autoFocus | ||||
|               style={{ | ||||
|                 width: '100%', | ||||
|                 padding: '10px', | ||||
|                 marginTop: '20px', | ||||
|                 marginBottom: '20px', | ||||
|                 borderRadius: '8px', | ||||
|                 border: '1px solid #ccc', | ||||
|                 fontSize: '16px', | ||||
|                 backgroundColor: '#1a1a1a', | ||||
|                 color: 'white' | ||||
|               }} | ||||
|             /> | ||||
|             <button | ||||
|               type="submit" | ||||
|               disabled={!handle.trim() || isProcessing} | ||||
|               style={{ | ||||
|                 padding: '12px 24px', | ||||
|                 backgroundColor: handle.trim() ? '#667eea' : '#444', | ||||
|                 color: 'white', | ||||
|                 border: 'none', | ||||
|                 borderRadius: '8px', | ||||
|                 cursor: handle.trim() ? 'pointer' : 'not-allowed', | ||||
|                 fontSize: '16px', | ||||
|                 fontWeight: 'bold', | ||||
|                 transition: 'all 0.3s ease', | ||||
|                 width: '100%' | ||||
|               }} | ||||
|             > | ||||
|               {isProcessing ? '処理中...' : '続行'} | ||||
|             </button> | ||||
|           </form> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   if (isProcessing) { | ||||
|     return ( | ||||
|       <div className="oauth-callback"> | ||||
|         <div className="oauth-processing"> | ||||
|           <div className="loading-spinner"></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return null; | ||||
| }; | ||||
|  | ||||
| // CSS styles (inline for simplicity) | ||||
| const styles = ` | ||||
| .oauth-callback { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100vw; | ||||
|   height: 100vh; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%); | ||||
|   color: #333; | ||||
|   z-index: 9999; | ||||
| } | ||||
|  | ||||
| .oauth-processing { | ||||
|   text-align: center; | ||||
|   padding: 40px; | ||||
|   background: rgba(255, 255, 255, 0.8); | ||||
|   border-radius: 16px; | ||||
|   backdrop-filter: blur(10px); | ||||
|   border: 1px solid rgba(0, 0, 0, 0.1); | ||||
|   box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); | ||||
| } | ||||
|  | ||||
| .loading-spinner { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   border: 3px solid rgba(0, 0, 0, 0.1); | ||||
|   border-top: 3px solid #1185fe; | ||||
|   border-radius: 50%; | ||||
|   animation: spin 1s linear infinite; | ||||
|   margin: 0 auto; | ||||
| } | ||||
|  | ||||
| @keyframes spin { | ||||
|   0% { transform: rotate(0deg); } | ||||
|   100% { transform: rotate(360deg); } | ||||
| } | ||||
|  | ||||
| `; | ||||
|  | ||||
| // Inject styles | ||||
| const styleSheet = document.createElement('style'); | ||||
| styleSheet.type = 'text/css'; | ||||
| styleSheet.innerText = styles; | ||||
| document.head.appendChild(styleSheet); | ||||
							
								
								
									
										42
									
								
								aicard-web-oauth/src/components/OAuthCallbackPage.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								aicard-web-oauth/src/components/OAuthCallbackPage.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| import React, { useEffect } from 'react'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import { OAuthCallback } from './OAuthCallback'; | ||||
|  | ||||
| export const OAuthCallbackPage: React.FC = () => { | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     console.log('=== OAUTH CALLBACK PAGE MOUNTED ==='); | ||||
|     console.log('Current URL:', window.location.href); | ||||
|     console.log('Search params:', window.location.search); | ||||
|     console.log('Pathname:', window.location.pathname); | ||||
|   }, []); | ||||
|  | ||||
|   const handleSuccess = (did: string, handle: string) => { | ||||
|     console.log('OAuth success, redirecting to home:', { did, handle }); | ||||
|      | ||||
|     // Add a small delay to ensure state is properly updated | ||||
|     setTimeout(() => { | ||||
|       navigate('/', { replace: true }); | ||||
|     }, 100); | ||||
|   }; | ||||
|  | ||||
|   const handleError = (error: string) => { | ||||
|     console.error('OAuth error, redirecting to home:', error); | ||||
|      | ||||
|     // Add a small delay before redirect | ||||
|     setTimeout(() => { | ||||
|       navigate('/', { replace: true }); | ||||
|     }, 2000); // Give user time to see error | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div> | ||||
|       <h2>Processing OAuth callback...</h2> | ||||
|       <OAuthCallback | ||||
|         onSuccess={handleSuccess} | ||||
|         onError={handleError} | ||||
|       /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										23
									
								
								aicard-web-oauth/src/main.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								aicard-web-oauth/src/main.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import React from 'react' | ||||
| import ReactDOM from 'react-dom/client' | ||||
| import { BrowserRouter, Routes, Route } from 'react-router-dom' | ||||
| import App from './App' | ||||
| import { OAuthCallbackPage } from './components/OAuthCallbackPage' | ||||
| import { CardList } from './components/CardList' | ||||
| import { OAuthEndpointHandler } from './utils/oauth-endpoints' | ||||
|  | ||||
| // Initialize OAuth endpoint handlers for dynamic client metadata and JWKS | ||||
| // DISABLED: This may interfere with BrowserOAuthClient | ||||
| // OAuthEndpointHandler.init() | ||||
|  | ||||
| ReactDOM.createRoot(document.getElementById('root')!).render( | ||||
|   <React.StrictMode> | ||||
|     <BrowserRouter> | ||||
|       <Routes> | ||||
|         <Route path="/oauth/callback" element={<OAuthCallbackPage />} /> | ||||
|         <Route path="/list" element={<CardList />} /> | ||||
|         <Route path="*" element={<App />} /> | ||||
|       </Routes> | ||||
|     </BrowserRouter> | ||||
|   </React.StrictMode>, | ||||
| ) | ||||
							
								
								
									
										107
									
								
								aicard-web-oauth/src/services/api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								aicard-web-oauth/src/services/api.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | ||||
| import axios from 'axios'; | ||||
| import { CardDrawResult } from '../types/card'; | ||||
|  | ||||
| // ai.card 直接APIアクセス(メイン) | ||||
| const API_HOST = import.meta.env.VITE_API_HOST || ''; | ||||
| const API_BASE = import.meta.env.PROD && API_HOST ? `${API_HOST}/api/v1` : '/api/v1'; | ||||
|  | ||||
| // ai.gpt MCP統合(オプション機能) | ||||
| const AI_GPT_BASE = import.meta.env.VITE_ENABLE_AI_FEATURES === 'true'  | ||||
|   ? (import.meta.env.PROD ? '/api/ai-gpt' : 'http://localhost:8001') | ||||
|   : null; | ||||
|  | ||||
| const cardApi_internal = axios.create({ | ||||
|   baseURL: API_BASE, | ||||
|   headers: { | ||||
|     'Content-Type': 'application/json', | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const aiGptApi = AI_GPT_BASE ? axios.create({ | ||||
|   baseURL: AI_GPT_BASE, | ||||
|   headers: { | ||||
|     'Content-Type': 'application/json', | ||||
|   }, | ||||
| }) : null; | ||||
|  | ||||
| // ai.cardの直接API(基本機能) | ||||
| export const cardApi = { | ||||
|   drawCard: async (userDid: string, isPaid: boolean = false): Promise<CardDrawResult> => { | ||||
|     const response = await cardApi_internal.post('/cards/draw', { | ||||
|       user_did: userDid, | ||||
|       is_paid: isPaid, | ||||
|     }); | ||||
|     return response.data; | ||||
|   }, | ||||
|  | ||||
|   getUserCards: async (userDid: string) => { | ||||
|     const response = await cardApi_internal.get(`/cards/user/${userDid}`); | ||||
|     return response.data; | ||||
|   }, | ||||
|  | ||||
|   getCardDetails: async (cardId: number) => { | ||||
|     const response = await cardApi_internal.get(`/cards/${cardId}`); | ||||
|     return response.data; | ||||
|   }, | ||||
|  | ||||
|   getUniqueCards: async () => { | ||||
|     const response = await cardApi_internal.get('/cards/unique'); | ||||
|     return response.data; | ||||
|   }, | ||||
|  | ||||
|   getGachaStats: async () => { | ||||
|     const response = await cardApi_internal.get('/cards/stats'); | ||||
|     return response.data; | ||||
|   }, | ||||
|  | ||||
|   // システム状態確認 | ||||
|   getSystemStatus: async () => { | ||||
|     const response = await cardApi_internal.get('/health'); | ||||
|     return response.data; | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| // ai.gpt統合API(オプション機能 - AI拡張) | ||||
| export const aiCardApi = { | ||||
|   analyzeCollection: async (userDid: string) => { | ||||
|     if (!aiGptApi) { | ||||
|       throw new Error('AI機能が無効化されています'); | ||||
|     } | ||||
|     try { | ||||
|       const response = await aiGptApi.get('/card_analyze_collection', { | ||||
|         params: { did: userDid } | ||||
|       }); | ||||
|       return response.data.data; | ||||
|     } catch (error) { | ||||
|       console.warn('ai.gpt AI分析機能が利用できません:', error); | ||||
|       throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です'); | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   getEnhancedStats: async () => { | ||||
|     if (!aiGptApi) { | ||||
|       throw new Error('AI機能が無効化されています'); | ||||
|     } | ||||
|     try { | ||||
|       const response = await aiGptApi.get('/card_get_gacha_stats'); | ||||
|       return response.data.data; | ||||
|     } catch (error) { | ||||
|       console.warn('ai.gpt AI統計機能が利用できません:', error); | ||||
|       throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です'); | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   // AI機能が利用可能かチェック | ||||
|   isAIAvailable: async (): Promise<boolean> => { | ||||
|     if (!aiGptApi || import.meta.env.VITE_ENABLE_AI_FEATURES !== 'true') { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       await aiGptApi.get('/health'); | ||||
|       return true; | ||||
|     } catch (error) { | ||||
|       return false; | ||||
|     } | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										684
									
								
								aicard-web-oauth/src/services/atproto-oauth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										684
									
								
								aicard-web-oauth/src/services/atproto-oauth.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,684 @@ | ||||
| import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; | ||||
| import { Agent } from '@atproto/api'; | ||||
|  | ||||
| interface AtprotoSession { | ||||
|   did: string; | ||||
|   handle: string; | ||||
|   accessJwt: string; | ||||
|   refreshJwt: string; | ||||
|   email?: string; | ||||
|   emailConfirmed?: boolean; | ||||
| } | ||||
|  | ||||
| class AtprotoOAuthService { | ||||
|   private oauthClient: BrowserOAuthClient | null = null; | ||||
|   private agent: Agent | null = null; | ||||
|   private initializePromise: Promise<void> | null = null; | ||||
|  | ||||
|   constructor() { | ||||
|     // Don't initialize immediately, wait for first use | ||||
|   } | ||||
|  | ||||
|   private async initialize(): Promise<void> { | ||||
|     // Prevent multiple initializations | ||||
|     if (this.initializePromise) { | ||||
|       return this.initializePromise; | ||||
|     } | ||||
|  | ||||
|     this.initializePromise = this._doInitialize(); | ||||
|     return this.initializePromise; | ||||
|   } | ||||
|  | ||||
|   private async _doInitialize(): Promise<void> { | ||||
|     try { | ||||
|       console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ==='); | ||||
|        | ||||
|       // Generate client ID based on current origin | ||||
|       const clientId = this.getClientId(); | ||||
|       console.log('Client ID:', clientId); | ||||
|        | ||||
|       // Support multiple PDS hosts for OAuth | ||||
|       this.oauthClient = await BrowserOAuthClient.load({ | ||||
|         clientId: clientId, | ||||
|         handleResolver: 'https://bsky.social', // Default resolver | ||||
|       }); | ||||
|        | ||||
|       console.log('BrowserOAuthClient initialized successfully with multi-PDS support'); | ||||
|        | ||||
|       // Try to restore existing session | ||||
|       const result = await this.oauthClient.init(); | ||||
|       if (result?.session) { | ||||
|         console.log('Existing session restored:', { | ||||
|           did: result.session.did, | ||||
|           handle: result.session.handle || 'unknown', | ||||
|           hasAccessJwt: !!result.session.accessJwt, | ||||
|           hasRefreshJwt: !!result.session.refreshJwt | ||||
|         }); | ||||
|          | ||||
|         // Create Agent instance with proper configuration | ||||
|         console.log('Creating Agent with session:', result.session); | ||||
|          | ||||
|         // Delete the old agent initialization code - we'll create it properly below | ||||
|          | ||||
|         // Set the session after creating the agent | ||||
|         // The session object from BrowserOAuthClient appears to be a special object | ||||
|         console.log('Full session object:', result.session); | ||||
|         console.log('Session type:', typeof result.session); | ||||
|         console.log('Session constructor:', result.session?.constructor?.name); | ||||
|          | ||||
|         // Try to iterate over the session object | ||||
|         if (result.session) { | ||||
|           console.log('Session properties:'); | ||||
|           for (const key in result.session) { | ||||
|             console.log(`  ${key}:`, result.session[key]); | ||||
|           } | ||||
|            | ||||
|           // Check if session has methods | ||||
|           const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session)); | ||||
|           console.log('Session methods:', methods); | ||||
|         } | ||||
|          | ||||
|         // BrowserOAuthClient might return a Session object that needs to be used with the agent | ||||
|         // Let's try to use the session object directly with the agent | ||||
|         if (result.session) { | ||||
|           // Process the session to extract DID and handle | ||||
|           const sessionData = await this.processSession(result.session); | ||||
|           console.log('Session processed during initialization:', sessionData); | ||||
|         } | ||||
|          | ||||
|       } else { | ||||
|         console.log('No existing session found'); | ||||
|       } | ||||
|        | ||||
|     } catch (error) { | ||||
|       console.error('Failed to initialize OAuth client:', error); | ||||
|       this.initializePromise = null; // Reset on error to allow retry | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private async processSession(session: any): Promise<{ did: string; handle: string }> { | ||||
|     console.log('Processing session:', session); | ||||
|      | ||||
|     // Log full session structure | ||||
|     console.log('Session structure:'); | ||||
|     console.log('- sub:', session.sub); | ||||
|     console.log('- did:', session.did); | ||||
|     console.log('- handle:', session.handle); | ||||
|     console.log('- iss:', session.iss); | ||||
|     console.log('- aud:', session.aud); | ||||
|      | ||||
|     // Check if agent has properties we can access | ||||
|     if (session.agent) { | ||||
|       console.log('- agent:', session.agent); | ||||
|       console.log('- agent.did:', session.agent?.did); | ||||
|       console.log('- agent.handle:', session.agent?.handle); | ||||
|     } | ||||
|      | ||||
|     const did = session.sub || session.did; | ||||
|     let handle = session.handle || 'unknown'; | ||||
|      | ||||
|     // Create Agent directly with session (per official docs) | ||||
|     try { | ||||
|       this.agent = new Agent(session); | ||||
|       console.log('Agent created directly with session'); | ||||
|        | ||||
|       // Check if agent has session info after creation | ||||
|       console.log('Agent after creation:'); | ||||
|       console.log('- agent.did:', this.agent.did); | ||||
|       console.log('- agent.session:', this.agent.session); | ||||
|       if (this.agent.session) { | ||||
|         console.log('- agent.session.did:', this.agent.session.did); | ||||
|         console.log('- agent.session.handle:', this.agent.session.handle); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       console.log('Failed to create Agent with session directly, trying dpopFetch method'); | ||||
|       // Fallback to dpopFetch method | ||||
|       this.agent = new Agent({ | ||||
|         service: session.server?.serviceEndpoint || 'https://bsky.social', | ||||
|         fetch: session.dpopFetch | ||||
|       }); | ||||
|     } | ||||
|      | ||||
|     // Store basic session info | ||||
|     (this as any)._sessionInfo = { did, handle }; | ||||
|      | ||||
|     // If handle is missing, try multiple methods to resolve it | ||||
|     if (!handle || handle === 'unknown') { | ||||
|       console.log('Handle not in session, attempting to resolve...'); | ||||
|        | ||||
|       // Method 1: Try using the agent to get profile | ||||
|       try { | ||||
|         await new Promise(resolve => setTimeout(resolve, 300)); | ||||
|         const profile = await this.agent.getProfile({ actor: did }); | ||||
|         if (profile.data.handle) { | ||||
|           handle = profile.data.handle; | ||||
|           (this as any)._sessionInfo.handle = handle; | ||||
|           console.log('Successfully resolved handle via getProfile:', handle); | ||||
|           return { did, handle }; | ||||
|         } | ||||
|       } catch (err) { | ||||
|         console.error('getProfile failed:', err); | ||||
|       } | ||||
|        | ||||
|       // Method 2: Try using describeRepo | ||||
|       try { | ||||
|         const repoDesc = await this.agent.com.atproto.repo.describeRepo({ | ||||
|           repo: did | ||||
|         }); | ||||
|         if (repoDesc.data.handle) { | ||||
|           handle = repoDesc.data.handle; | ||||
|           (this as any)._sessionInfo.handle = handle; | ||||
|           console.log('Got handle from describeRepo:', handle); | ||||
|           return { did, handle }; | ||||
|         } | ||||
|       } catch (err) { | ||||
|         console.error('describeRepo failed:', err); | ||||
|       } | ||||
|        | ||||
|       // Method 3: Hardcoded fallback for known DIDs | ||||
|       if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') { | ||||
|         handle = 'syui.ai'; | ||||
|         (this as any)._sessionInfo.handle = handle; | ||||
|         console.log('Using hardcoded handle for known DID'); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return { did, handle }; | ||||
|   } | ||||
|  | ||||
|   private getClientId(): string { | ||||
|     const origin = window.location.origin; | ||||
|      | ||||
|     // For production (xxxcard.syui.ai), use the actual URL | ||||
|     if (origin.includes('xxxcard.syui.ai')) { | ||||
|       return `${origin}/client-metadata.json`; | ||||
|     } | ||||
|      | ||||
|     // For localhost development, use undefined for loopback client | ||||
|     // The BrowserOAuthClient will handle this automatically | ||||
|     if (origin.includes('localhost') || origin.includes('127.0.0.1')) { | ||||
|       console.log('Using loopback client for localhost development'); | ||||
|       return undefined as any; // Loopback client | ||||
|     } | ||||
|      | ||||
|     // Default: use origin-based client metadata | ||||
|     return `${origin}/client-metadata.json`; | ||||
|   } | ||||
|  | ||||
|   private detectPDSFromHandle(handle: string): string { | ||||
|     console.log('Detecting PDS for handle:', handle); | ||||
|      | ||||
|     // Supported PDS hosts and their corresponding handles | ||||
|     const pdsMapping = { | ||||
|       'syu.is': 'https://syu.is', | ||||
|       'bsky.social': 'https://bsky.social', | ||||
|     }; | ||||
|      | ||||
|     // Check if handle ends with known PDS domains | ||||
|     for (const [domain, pdsUrl] of Object.entries(pdsMapping)) { | ||||
|       if (handle.endsWith(`.${domain}`)) { | ||||
|         console.log(`Handle ${handle} mapped to PDS: ${pdsUrl}`); | ||||
|         return pdsUrl; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Default to bsky.social | ||||
|     console.log(`Handle ${handle} using default PDS: https://bsky.social`); | ||||
|     return 'https://bsky.social'; | ||||
|   } | ||||
|  | ||||
|   async initiateOAuthFlow(handle?: string): Promise<void> { | ||||
|     try { | ||||
|       console.log('=== INITIATING OAUTH FLOW ==='); | ||||
|        | ||||
|       if (!this.oauthClient) { | ||||
|         console.log('OAuth client not initialized, initializing now...'); | ||||
|         await this.initialize(); | ||||
|       } | ||||
|  | ||||
|       if (!this.oauthClient) { | ||||
|         throw new Error('Failed to initialize OAuth client'); | ||||
|       } | ||||
|  | ||||
|       // If handle is not provided, prompt user | ||||
|       if (!handle) { | ||||
|         handle = prompt('ハンドルを入力してください (例: user.bsky.social または user.syu.is):'); | ||||
|         if (!handle) { | ||||
|           throw new Error('Handle is required for authentication'); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       console.log('Starting OAuth flow for handle:', handle); | ||||
|        | ||||
|       // Detect PDS based on handle | ||||
|       const pdsUrl = this.detectPDSFromHandle(handle); | ||||
|       console.log('Detected PDS for handle:', { handle, pdsUrl }); | ||||
|        | ||||
|       // Re-initialize OAuth client with correct PDS if needed | ||||
|       if (pdsUrl !== 'https://bsky.social') { | ||||
|         console.log('Re-initializing OAuth client for custom PDS:', pdsUrl); | ||||
|         this.oauthClient = await BrowserOAuthClient.load({ | ||||
|           clientId: this.getClientId(), | ||||
|           handleResolver: pdsUrl, | ||||
|         }); | ||||
|       } | ||||
|        | ||||
|       // Start OAuth authorization flow | ||||
|       console.log('Calling oauthClient.authorize with handle:', handle); | ||||
|        | ||||
|       try { | ||||
|         const authUrl = await this.oauthClient.authorize(handle, { | ||||
|           scope: 'atproto transition:generic', | ||||
|         }); | ||||
|  | ||||
|         console.log('Authorization URL generated:', authUrl.toString()); | ||||
|         console.log('URL breakdown:', { | ||||
|           protocol: authUrl.protocol, | ||||
|           hostname: authUrl.hostname, | ||||
|           pathname: authUrl.pathname, | ||||
|           search: authUrl.search | ||||
|         }); | ||||
|          | ||||
|         // Store some debug info before redirect | ||||
|         sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({ | ||||
|           timestamp: new Date().toISOString(), | ||||
|           handle: handle, | ||||
|           authUrl: authUrl.toString(), | ||||
|           currentUrl: window.location.href | ||||
|         })); | ||||
|          | ||||
|         // Redirect to authorization server | ||||
|         console.log('About to redirect to:', authUrl.toString()); | ||||
|         window.location.href = authUrl.toString(); | ||||
|       } catch (authorizeError) { | ||||
|         console.error('oauthClient.authorize failed:', authorizeError); | ||||
|         console.error('Error details:', { | ||||
|           name: authorizeError.name, | ||||
|           message: authorizeError.message, | ||||
|           stack: authorizeError.stack | ||||
|         }); | ||||
|         throw authorizeError; | ||||
|       } | ||||
|        | ||||
|     } catch (error) { | ||||
|       console.error('Failed to initiate OAuth flow:', error); | ||||
|       throw new Error(`OAuth認証の開始に失敗しました: ${error}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> { | ||||
|     try { | ||||
|       console.log('=== HANDLING OAUTH CALLBACK ==='); | ||||
|       console.log('Current URL:', window.location.href); | ||||
|       console.log('URL hash:', window.location.hash); | ||||
|       console.log('URL search:', window.location.search); | ||||
|        | ||||
|       // BrowserOAuthClient should automatically handle the callback | ||||
|       // We just need to initialize it and it will process the current URL | ||||
|       if (!this.oauthClient) { | ||||
|         console.log('OAuth client not initialized, initializing now...'); | ||||
|         await this.initialize(); | ||||
|       } | ||||
|  | ||||
|       if (!this.oauthClient) { | ||||
|         throw new Error('Failed to initialize OAuth client'); | ||||
|       } | ||||
|  | ||||
|       console.log('OAuth client ready, initializing to process callback...'); | ||||
|        | ||||
|       // Call init() again to process the callback URL | ||||
|       const result = await this.oauthClient.init(); | ||||
|       console.log('OAuth callback processing result:', result); | ||||
|        | ||||
|       if (result?.session) { | ||||
|         // Process the session | ||||
|         return this.processSession(result.session); | ||||
|       } | ||||
|        | ||||
|       // If no session yet, wait a bit and try again | ||||
|       console.log('No session found immediately, waiting...'); | ||||
|       await new Promise(resolve => setTimeout(resolve, 1000)); | ||||
|        | ||||
|       // Try to check session again | ||||
|       const sessionCheck = await this.checkSession(); | ||||
|       if (sessionCheck) { | ||||
|         console.log('Session found after delay:', sessionCheck); | ||||
|         return sessionCheck; | ||||
|       } | ||||
|        | ||||
|       console.warn('OAuth callback completed but no session was created'); | ||||
|       return null; | ||||
|        | ||||
|     } catch (error) { | ||||
|       console.error('OAuth callback handling failed:', error); | ||||
|       console.error('Error details:', { | ||||
|         name: error.name, | ||||
|         message: error.message, | ||||
|         stack: error.stack | ||||
|       }); | ||||
|       throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async checkSession(): Promise<{ did: string; handle: string } | null> { | ||||
|     try { | ||||
|       console.log('=== CHECK SESSION CALLED ==='); | ||||
|        | ||||
|       if (!this.oauthClient) { | ||||
|         console.log('No OAuth client, initializing...'); | ||||
|         await this.initialize(); | ||||
|       } | ||||
|  | ||||
|       if (!this.oauthClient) { | ||||
|         console.log('OAuth client initialization failed'); | ||||
|         return null; | ||||
|       } | ||||
|  | ||||
|       console.log('Running oauthClient.init() to check session...'); | ||||
|       const result = await this.oauthClient.init(); | ||||
|       console.log('oauthClient.init() result:', result); | ||||
|        | ||||
|       if (result?.session) { | ||||
|         // Use the common session processing method | ||||
|         return this.processSession(result.session); | ||||
|       } | ||||
|        | ||||
|       return null; | ||||
|     } catch (error) { | ||||
|       console.error('Session check failed:', error); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   getAgent(): Agent | null { | ||||
|     return this.agent; | ||||
|   } | ||||
|  | ||||
|   getSession(): AtprotoSession | null { | ||||
|     console.log('getSession called'); | ||||
|     console.log('Current state:', { | ||||
|       hasAgent: !!this.agent, | ||||
|       hasAgentSession: !!this.agent?.session, | ||||
|       hasOAuthClient: !!this.oauthClient, | ||||
|       hasSessionInfo: !!(this as any)._sessionInfo | ||||
|     }); | ||||
|      | ||||
|     // First check if we have an agent with session | ||||
|     if (this.agent?.session) { | ||||
|       const session = { | ||||
|         did: this.agent.session.did, | ||||
|         handle: this.agent.session.handle || 'unknown', | ||||
|         accessJwt: this.agent.session.accessJwt || '', | ||||
|         refreshJwt: this.agent.session.refreshJwt || '', | ||||
|       }; | ||||
|       console.log('Returning agent session:', session); | ||||
|       return session; | ||||
|     } | ||||
|      | ||||
|     // If no agent.session but we have stored session info, return that | ||||
|     if ((this as any)._sessionInfo) { | ||||
|       const session = { | ||||
|         did: (this as any)._sessionInfo.did, | ||||
|         handle: (this as any)._sessionInfo.handle, | ||||
|         accessJwt: 'dpop-protected',  // Indicate that tokens are handled by dpopFetch | ||||
|         refreshJwt: 'dpop-protected', | ||||
|       }; | ||||
|       console.log('Returning stored session info:', session); | ||||
|       return session; | ||||
|     } | ||||
|      | ||||
|     console.log('No session available'); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   isAuthenticated(): boolean { | ||||
|     return !!this.agent || !!(this as any)._sessionInfo; | ||||
|   } | ||||
|  | ||||
|   getUser(): { did: string; handle: string } | null { | ||||
|     const session = this.getSession(); | ||||
|     if (!session) return null; | ||||
|      | ||||
|     return { | ||||
|       did: session.did, | ||||
|       handle: session.handle | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   async logout(): Promise<void> { | ||||
|     try { | ||||
|       console.log('=== LOGGING OUT ==='); | ||||
|        | ||||
|       // Clear Agent | ||||
|       this.agent = null; | ||||
|       console.log('Agent cleared'); | ||||
|        | ||||
|       // Clear BrowserOAuthClient session | ||||
|       if (this.oauthClient) { | ||||
|         console.log('Clearing OAuth client session...'); | ||||
|         try { | ||||
|           // BrowserOAuthClient may have a revoke or signOut method | ||||
|           if (typeof (this.oauthClient as any).signOut === 'function') { | ||||
|             await (this.oauthClient as any).signOut(); | ||||
|             console.log('OAuth client signed out'); | ||||
|           } else if (typeof (this.oauthClient as any).revoke === 'function') { | ||||
|             await (this.oauthClient as any).revoke(); | ||||
|             console.log('OAuth client revoked'); | ||||
|           } else { | ||||
|             console.log('No explicit signOut method found on OAuth client'); | ||||
|           } | ||||
|         } catch (oauthError) { | ||||
|           console.error('OAuth client logout error:', oauthError); | ||||
|         } | ||||
|          | ||||
|         // Reset the OAuth client to force re-initialization | ||||
|         this.oauthClient = null; | ||||
|         this.initializePromise = null; | ||||
|       } | ||||
|        | ||||
|       // Clear any stored session data | ||||
|       localStorage.removeItem('atproto_session'); | ||||
|       sessionStorage.clear(); | ||||
|        | ||||
|       // Clear all localStorage items that might be related to OAuth | ||||
|       const keysToRemove: string[] = []; | ||||
|       for (let i = 0; i < localStorage.length; i++) { | ||||
|         const key = localStorage.key(i); | ||||
|         if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) { | ||||
|           keysToRemove.push(key); | ||||
|         } | ||||
|       } | ||||
|       keysToRemove.forEach(key => { | ||||
|         console.log('Removing localStorage key:', key); | ||||
|         localStorage.removeItem(key); | ||||
|       }); | ||||
|        | ||||
|       console.log('=== LOGOUT COMPLETED ==='); | ||||
|        | ||||
|       // Force page reload to ensure clean state | ||||
|       setTimeout(() => { | ||||
|         window.location.reload(); | ||||
|       }, 100); | ||||
|        | ||||
|     } catch (error) { | ||||
|       console.error('Logout failed:', error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // カードデータをatproto collectionに保存 | ||||
|   async saveCardToBox(userCards: any[]): Promise<void> { | ||||
|     // Ensure we have a valid session | ||||
|     const sessionInfo = await this.checkSession(); | ||||
|     if (!sessionInfo) { | ||||
|       throw new Error('認証が必要です。ログインしてください。'); | ||||
|     } | ||||
|  | ||||
|     const did = sessionInfo.did; | ||||
|  | ||||
|     try { | ||||
|       console.log('Saving cards to atproto collection...'); | ||||
|       console.log('Using DID:', did); | ||||
|        | ||||
|       // Ensure we have a fresh agent | ||||
|       if (!this.agent) { | ||||
|         throw new Error('Agentが初期化されていません。'); | ||||
|       } | ||||
|        | ||||
|       const collection = 'ai.card.box'; | ||||
|       const rkey = 'self'; | ||||
|       const createdAt = new Date().toISOString(); | ||||
|  | ||||
|       // カードボックスのレコード | ||||
|       const record = { | ||||
|         $type: 'ai.card.box', | ||||
|         cards: userCards.map(card => ({ | ||||
|           id: card.id, | ||||
|           cp: card.cp, | ||||
|           status: card.status, | ||||
|           skill: card.skill, | ||||
|           owner_did: card.owner_did, | ||||
|           obtained_at: card.obtained_at, | ||||
|           is_unique: card.is_unique, | ||||
|           unique_id: card.unique_id | ||||
|  | ||||
|         })), | ||||
|         total_cards: userCards.length, | ||||
|         updated_at: createdAt, | ||||
|         createdAt: createdAt | ||||
|       }; | ||||
|  | ||||
|       console.log('PutRecord request:', { | ||||
|         repo: did, | ||||
|         collection: collection, | ||||
|         rkey: rkey, | ||||
|         record: record | ||||
|       }); | ||||
|  | ||||
|  | ||||
|       // Use Agent's com.atproto.repo.putRecord method | ||||
|       const response = await this.agent.com.atproto.repo.putRecord({ | ||||
|         repo: did, | ||||
|         collection: collection, | ||||
|         rkey: rkey, | ||||
|         record: record | ||||
|       }); | ||||
|  | ||||
|       console.log('カードデータをai.card.boxに保存しました:', response); | ||||
|     } catch (error) { | ||||
|       console.error('カードボックス保存エラー:', error); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // ai.card.boxからカード一覧を取得 | ||||
|   async getCardsFromBox(): Promise<any> { | ||||
|     // Ensure we have a valid session | ||||
|     const sessionInfo = await this.checkSession(); | ||||
|     if (!sessionInfo) { | ||||
|       throw new Error('認証が必要です。ログインしてください。'); | ||||
|     } | ||||
|  | ||||
|     const did = sessionInfo.did; | ||||
|  | ||||
|     try { | ||||
|       console.log('Fetching cards from atproto collection...'); | ||||
|       console.log('Using DID:', did); | ||||
|        | ||||
|       // Ensure we have a fresh agent | ||||
|       if (!this.agent) { | ||||
|         throw new Error('Agentが初期化されていません。'); | ||||
|       } | ||||
|        | ||||
|       const response = await this.agent.com.atproto.repo.getRecord({ | ||||
|         repo: did, | ||||
|         collection: 'ai.card.box', | ||||
|         rkey: 'self' | ||||
|       }); | ||||
|  | ||||
|       console.log('Cards from box response:', response); | ||||
|        | ||||
|       // Convert to expected format | ||||
|       const result = { | ||||
|         records: [{ | ||||
|           uri: `at://${did}/ai.card.box/self`, | ||||
|           cid: response.data.cid, | ||||
|           value: response.data.value | ||||
|         }] | ||||
|       }; | ||||
|        | ||||
|       return result; | ||||
|     } catch (error) { | ||||
|       console.error('カードボックス取得エラー:', error); | ||||
|        | ||||
|       // If record doesn't exist, return empty | ||||
|       if (error.toString().includes('RecordNotFound')) { | ||||
|         return { records: [] }; | ||||
|       } | ||||
|        | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // ai.card.boxのコレクションを削除 | ||||
|   async deleteCardBox(): Promise<void> { | ||||
|     // Ensure we have a valid session | ||||
|     const sessionInfo = await this.checkSession(); | ||||
|     if (!sessionInfo) { | ||||
|       throw new Error('認証が必要です。ログインしてください。'); | ||||
|     } | ||||
|  | ||||
|     const did = sessionInfo.did; | ||||
|  | ||||
|     try { | ||||
|       console.log('Deleting card box collection...'); | ||||
|       console.log('Using DID:', did); | ||||
|        | ||||
|       // Ensure we have a fresh agent | ||||
|       if (!this.agent) { | ||||
|         throw new Error('Agentが初期化されていません。'); | ||||
|       } | ||||
|        | ||||
|       const response = await this.agent.com.atproto.repo.deleteRecord({ | ||||
|         repo: did, | ||||
|         collection: 'ai.card.box', | ||||
|         rkey: 'self' | ||||
|       }); | ||||
|  | ||||
|       console.log('Card box deleted successfully:', response); | ||||
|     } catch (error) { | ||||
|       console.error('カードボックス削除エラー:', error); | ||||
|       throw error; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // 手動でトークンを設定(開発・デバッグ用) | ||||
|   setManualTokens(accessJwt: string, refreshJwt: string): void { | ||||
|     console.warn('Manual token setting is not supported with official BrowserOAuthClient'); | ||||
|     console.warn('Please use the proper OAuth flow instead'); | ||||
|      | ||||
|     // For backward compatibility, store in localStorage | ||||
|     const session: AtprotoSession = { | ||||
|       did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn', | ||||
|       handle: 'syui.ai', | ||||
|       accessJwt: accessJwt, | ||||
|       refreshJwt: refreshJwt | ||||
|     }; | ||||
|      | ||||
|     localStorage.setItem('atproto_session', JSON.stringify(session)); | ||||
|     console.log('Manual tokens stored in localStorage for backward compatibility'); | ||||
|   } | ||||
|  | ||||
|   // 後方互換性のための従来関数 | ||||
|   saveSessionToStorage(session: AtprotoSession): void { | ||||
|     console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient'); | ||||
|     localStorage.setItem('atproto_session', JSON.stringify(session)); | ||||
|   } | ||||
|  | ||||
|   async backupUserCards(userCards: any[]): Promise<void> { | ||||
|     return this.saveCardToBox(userCards); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const atprotoOAuthService = new AtprotoOAuthService(); | ||||
| export type { AtprotoSession }; | ||||
							
								
								
									
										109
									
								
								aicard-web-oauth/src/services/auth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								aicard-web-oauth/src/services/auth.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| import axios from 'axios'; | ||||
|  | ||||
| const API_BASE = '/api/v1'; | ||||
|  | ||||
| interface LoginRequest { | ||||
|   identifier: string;  // Handle or DID | ||||
|   password: string;    // App password | ||||
| } | ||||
|  | ||||
| interface LoginResponse { | ||||
|   access_token: string; | ||||
|   token_type: string; | ||||
|   did: string; | ||||
|   handle: string; | ||||
| } | ||||
|  | ||||
| interface User { | ||||
|   did: string; | ||||
|   handle: string; | ||||
|   avatar?: string; | ||||
|   displayName?: string; | ||||
| } | ||||
|  | ||||
| class AuthService { | ||||
|   private token: string | null = null; | ||||
|   private user: User | null = null; | ||||
|  | ||||
|   constructor() { | ||||
|     // Load token from localStorage | ||||
|     this.token = localStorage.getItem('ai_card_token'); | ||||
|      | ||||
|     // Set default auth header if token exists | ||||
|     if (this.token) { | ||||
|       axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async login(identifier: string, password: string): Promise<LoginResponse> { | ||||
|     try { | ||||
|       const response = await axios.post<LoginResponse>(`${API_BASE}/auth/login`, { | ||||
|         identifier, | ||||
|         password | ||||
|       }); | ||||
|  | ||||
|       const { access_token, did, handle } = response.data; | ||||
|  | ||||
|       // Store token | ||||
|       this.token = access_token; | ||||
|       localStorage.setItem('ai_card_token', access_token); | ||||
|        | ||||
|       // Set auth header | ||||
|       axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`; | ||||
|  | ||||
|       // Store user info | ||||
|       this.user = { did, handle }; | ||||
|  | ||||
|       return response.data; | ||||
|     } catch (error) { | ||||
|       throw new Error('Login failed'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async logout(): Promise<void> { | ||||
|     try { | ||||
|       await axios.post(`${API_BASE}/auth/logout`); | ||||
|     } catch (error) { | ||||
|       // Ignore errors | ||||
|     } | ||||
|  | ||||
|     // Clear token | ||||
|     this.token = null; | ||||
|     this.user = null; | ||||
|     localStorage.removeItem('ai_card_token'); | ||||
|     delete axios.defaults.headers.common['Authorization']; | ||||
|   } | ||||
|  | ||||
|   async verify(): Promise<User | null> { | ||||
|     if (!this.token) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       const response = await axios.get<User & { valid: boolean }>(`${API_BASE}/auth/verify`); | ||||
|       if (response.data.valid) { | ||||
|         this.user = { | ||||
|           did: response.data.did, | ||||
|           handle: response.data.handle | ||||
|         }; | ||||
|         return this.user; | ||||
|       } | ||||
|     } catch (error) { | ||||
|       // Token is invalid | ||||
|       this.logout(); | ||||
|     } | ||||
|  | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   getUser(): User | null { | ||||
|     return this.user; | ||||
|   } | ||||
|  | ||||
|   isAuthenticated(): boolean { | ||||
|     return this.token !== null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const authService = new AuthService(); | ||||
| export type { User, LoginRequest, LoginResponse }; | ||||
							
								
								
									
										331
									
								
								aicard-web-oauth/src/styles/Card.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										331
									
								
								aicard-web-oauth/src/styles/Card.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,331 @@ | ||||
| .card { | ||||
|   width: 250px; | ||||
|   height: 380px; | ||||
|   border-radius: 12px; | ||||
|   background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); | ||||
|   border: 2px solid #333; | ||||
|   overflow: hidden; | ||||
|   position: relative; | ||||
|   cursor: pointer; | ||||
|   transition: transform 0.3s ease; | ||||
| } | ||||
|  | ||||
| .card:hover { | ||||
|   transform: translateY(-5px); | ||||
| } | ||||
|  | ||||
| .card-inner { | ||||
|   padding: 20px; | ||||
|   height: 100%; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   position: relative; | ||||
|   z-index: 1; | ||||
| } | ||||
|  | ||||
| /* Rarity effects */ | ||||
| .card-normal { | ||||
|   border-color: #666; | ||||
| } | ||||
|  | ||||
| .card-rare { | ||||
|   border-color: #4a90e2; | ||||
|   background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); | ||||
| } | ||||
|  | ||||
| .card-super-rare { | ||||
|   border-color: #9c27b0; | ||||
|   background: linear-gradient(135deg, #2d1b69 0%, #0f0c29 100%); | ||||
| } | ||||
|  | ||||
| .card-kira { | ||||
|   border-color: #ffd700; | ||||
|   background: linear-gradient(135deg, #232526 0%, #414345 100%); | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .card-kira::before { | ||||
|   content: ''; | ||||
|   position: absolute; | ||||
|   top: -50%; | ||||
|   left: -50%; | ||||
|   width: 200%; | ||||
|   height: 200%; | ||||
|   background: linear-gradient( | ||||
|     45deg, | ||||
|     transparent 30%, | ||||
|     rgba(255, 215, 0, 0.1) 50%, | ||||
|     transparent 70% | ||||
|   ); | ||||
|   animation: shimmer 3s infinite; | ||||
| } | ||||
|  | ||||
| .card-unique { | ||||
|   border-color: #ff00ff; | ||||
|   background: linear-gradient(135deg, #000000 0%, #1a0033 100%); | ||||
|   box-shadow: 0 0 30px rgba(255, 0, 255, 0.5); | ||||
| } | ||||
|  | ||||
| .card-unique::before { | ||||
|   content: ''; | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   background: radial-gradient( | ||||
|     circle at center, | ||||
|     transparent 0%, | ||||
|     rgba(255, 0, 255, 0.2) 100% | ||||
|   ); | ||||
|   animation: pulse 2s infinite; | ||||
| } | ||||
|  | ||||
| /* Card content */ | ||||
| .card-header { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   font-size: 14px; | ||||
|   color: #888; | ||||
|   margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .card-image-container { | ||||
|   width: 100%; | ||||
|   height: 150px; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   margin-bottom: 15px; | ||||
|   overflow: hidden; | ||||
|   border-radius: 8px; | ||||
|   background: rgba(255, 255, 255, 0.05); | ||||
| } | ||||
|  | ||||
| .card-image { | ||||
|   max-width: 100%; | ||||
|   max-height: 100%; | ||||
|   object-fit: contain; | ||||
|   border-radius: 8px; | ||||
| } | ||||
|  | ||||
| .card-content { | ||||
|   flex: 1; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
| } | ||||
|  | ||||
| .card-name { | ||||
|   font-size: 28px; | ||||
|   margin: 0; | ||||
|   color: var(--card-color, #fff); | ||||
|   text-align: center; | ||||
|   font-weight: bold; | ||||
| } | ||||
|  | ||||
| .unique-badge { | ||||
|   margin-top: 10px; | ||||
|   padding: 5px 15px; | ||||
|   background: linear-gradient(90deg, #ff00ff, #00ffff); | ||||
|   border-radius: 20px; | ||||
|   font-size: 12px; | ||||
|   font-weight: bold; | ||||
|   animation: glow 2s ease-in-out infinite; | ||||
| } | ||||
|  | ||||
| .card-skill { | ||||
|   margin-top: 20px; | ||||
|   padding: 10px; | ||||
|   background: rgba(255, 255, 255, 0.1); | ||||
|   border-radius: 8px; | ||||
|   font-size: 12px; | ||||
| } | ||||
|  | ||||
| .card-footer { | ||||
|   text-align: center; | ||||
|   font-size: 12px; | ||||
|   color: #666; | ||||
|   text-transform: uppercase; | ||||
|   letter-spacing: 1px; | ||||
| } | ||||
|  | ||||
| /* Animations */ | ||||
| @keyframes shimmer { | ||||
|   0% { transform: translateX(-100%); } | ||||
|   100% { transform: translateX(100%); } | ||||
| } | ||||
|  | ||||
| @keyframes pulse { | ||||
|   0% { opacity: 0.5; } | ||||
|   50% { opacity: 1; } | ||||
|   100% { opacity: 0.5; } | ||||
| } | ||||
|  | ||||
| @keyframes glow { | ||||
|   0% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); } | ||||
|   50% { box-shadow: 0 0 20px rgba(255, 0, 255, 0.8); } | ||||
|   100% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); } | ||||
| } | ||||
|  | ||||
| /* Simple Card Styles */ | ||||
| .card-simple { | ||||
|   width: 240px; | ||||
|   height: auto; | ||||
|   background: transparent; | ||||
|   border: none; | ||||
|   padding: 0; | ||||
| } | ||||
|  | ||||
| .card-frame { | ||||
|   position: relative; | ||||
|   width: 100%; | ||||
|   aspect-ratio: 3/4; | ||||
|   border-radius: 8px; | ||||
|   overflow: hidden; | ||||
|   background: #1a1a1a; | ||||
|   padding: 25px 25px 30px 25px; | ||||
|   border: 3px solid #666; | ||||
|   box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| /* Normal card - no effects */ | ||||
| .card-simple.card-normal .card-frame { | ||||
|   border-color: #666; | ||||
|   background: #1a1a1a; | ||||
| } | ||||
|  | ||||
| /* Unique (rare) card - glowing effects */ | ||||
| .card-simple.card-unique .card-frame { | ||||
|   border-color: #ffd700; | ||||
|   background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%); | ||||
|   position: relative; | ||||
|   isolation: isolate; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| /* Particle/grainy texture for rare cards */ | ||||
| .card-simple.card-unique .card-frame::before { | ||||
|   content: ""; | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   background-image:  | ||||
|     repeating-radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.1) 0px, transparent 1px, transparent 2px), | ||||
|     repeating-radial-gradient(circle at 3px 3px, rgba(255, 215, 0, 0.1) 0px, transparent 2px, transparent 4px); | ||||
|   background-size: 20px 20px, 30px 30px; | ||||
|   opacity: 0.8; | ||||
|   z-index: 1; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| /* Reflection effect for rare cards */ | ||||
| .card-simple.card-unique .card-frame::after { | ||||
|   content: ""; | ||||
|   height: 100%; | ||||
|   width: 40px; | ||||
|   position: absolute; | ||||
|   top: -180px; | ||||
|   left: 0; | ||||
|   background: linear-gradient(90deg,  | ||||
|     transparent 0%, | ||||
|     rgba(255, 215, 0, 0.8) 20%, | ||||
|     rgba(255, 255, 0, 0.9) 40%, | ||||
|     rgba(255, 223, 0, 1) 50%, | ||||
|     rgba(255, 255, 0, 0.9) 60%, | ||||
|     rgba(255, 215, 0, 0.8) 80%, | ||||
|     transparent 100% | ||||
|   ); | ||||
|   opacity: 0; | ||||
|   transform: rotate(45deg); | ||||
|   animation: gold-reflection 6s ease-in-out infinite; | ||||
|   z-index: 2; | ||||
| } | ||||
|  | ||||
| @keyframes gold-reflection { | ||||
|   0% { transform: scale(0) rotate(45deg); opacity: 0; } | ||||
|   15% { transform: scale(0) rotate(45deg); opacity: 0; } | ||||
|   17% { transform: scale(4) rotate(45deg); opacity: 0.8; } | ||||
|   20% { transform: scale(50) rotate(45deg); opacity: 0; } | ||||
|   100% { transform: scale(50) rotate(45deg); opacity: 0; } | ||||
| } | ||||
|  | ||||
| /* Glowing backlight effect */ | ||||
| .card-simple.card-unique { | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .card-simple.card-unique::after { | ||||
|   position: absolute; | ||||
|   content: ""; | ||||
|   top: 5px; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   z-index: -1; | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
|   margin: 0 auto; | ||||
|   transform: scale(0.95); | ||||
|   filter: blur(15px); | ||||
|   background: radial-gradient(ellipse at center, #ffd700 0%, #ffb347 50%, transparent 70%); | ||||
|   opacity: 0.6; | ||||
| } | ||||
|  | ||||
| /* Glowing border effect for rare cards */ | ||||
| .card-simple.card-unique .card-frame { | ||||
|   box-shadow:  | ||||
|     0 0 10px rgba(255, 215, 0, 0.5), | ||||
|     inset 0 0 10px rgba(255, 215, 0, 0.1); | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| .card-image-simple { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   object-fit: cover; | ||||
|   border-radius: 4px; | ||||
|   position: relative; | ||||
|   z-index: 1; | ||||
| } | ||||
|  | ||||
| .card-cp-bar { | ||||
|   width: 100%; | ||||
|   height: 50px; | ||||
|   background: #333; | ||||
|   border-radius: 6px; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   margin-top: 12px; | ||||
|   margin-bottom: 8px; | ||||
|   border: 2px solid #666; | ||||
|   position: relative; | ||||
|   box-sizing: border-box; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .card-simple.card-unique .card-cp-bar { | ||||
|   background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%); | ||||
|   border-color: #ffd700; | ||||
|   box-shadow:  | ||||
|     0 0 5px rgba(255, 215, 0, 0.3), | ||||
|     inset 0 0 5px rgba(255, 215, 0, 0.1); | ||||
| } | ||||
|  | ||||
|  | ||||
| .cp-value { | ||||
|   font-size: 20px; | ||||
|   font-weight: bold; | ||||
|   color: #fff; | ||||
|   text-shadow: 0 0 10px rgba(0, 0, 0, 0.5); | ||||
|   z-index: 1; | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
							
								
								
									
										196
									
								
								aicard-web-oauth/src/styles/CardBox.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								aicard-web-oauth/src/styles/CardBox.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,196 @@ | ||||
| .card-box-container { | ||||
|   max-width: 1200px; | ||||
|   margin: 0 auto; | ||||
|   padding: 20px; | ||||
| } | ||||
|  | ||||
| .card-box-header { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   margin-bottom: 20px; | ||||
|   padding-bottom: 15px; | ||||
|   border-bottom: 2px solid #e9ecef; | ||||
| } | ||||
|  | ||||
| .card-box-header h3 { | ||||
|   color: #495057; | ||||
|   margin: 0; | ||||
|   font-size: 24px; | ||||
| } | ||||
|  | ||||
| .box-actions { | ||||
|   display: flex; | ||||
|   gap: 10px; | ||||
| } | ||||
|  | ||||
| .uri-display { | ||||
|   background: #e3f2fd; | ||||
|   border: 1px solid #bbdefb; | ||||
|   border-radius: 8px; | ||||
|   padding: 12px; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .uri-display p { | ||||
|   margin: 0; | ||||
|   color: #1565c0; | ||||
|   font-size: 14px; | ||||
| } | ||||
|  | ||||
| .uri-display code { | ||||
|   background: #ffffff; | ||||
|   border: 1px solid #90caf9; | ||||
|   border-radius: 4px; | ||||
|   padding: 4px 8px; | ||||
|   font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; | ||||
|   font-size: 12px; | ||||
|   color: #0d47a1; | ||||
|   word-break: break-all; | ||||
| } | ||||
|  | ||||
| .json-button, | ||||
| .refresh-button, | ||||
| .retry-button, | ||||
| .delete-button { | ||||
|   padding: 8px 16px; | ||||
|   border: none; | ||||
|   border-radius: 8px; | ||||
|   font-size: 14px; | ||||
|   font-weight: bold; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
| } | ||||
|  | ||||
| .json-button { | ||||
|   background: linear-gradient(135deg, #6f42c1 0%, #8b5fc3 100%); | ||||
|   color: white; | ||||
| } | ||||
|  | ||||
| .json-button:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4); | ||||
| } | ||||
|  | ||||
| .refresh-button { | ||||
|   background: linear-gradient(135deg, #17a2b8 0%, #20c997 100%); | ||||
|   color: white; | ||||
| } | ||||
|  | ||||
| .refresh-button:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(23, 162, 184, 0.4); | ||||
| } | ||||
|  | ||||
| .retry-button { | ||||
|   background: linear-gradient(135deg, #fd7e14 0%, #ffc107 100%); | ||||
|   color: white; | ||||
| } | ||||
|  | ||||
| .retry-button:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(253, 126, 20, 0.4); | ||||
| } | ||||
|  | ||||
| .delete-button { | ||||
|   background: linear-gradient(135deg, #dc3545 0%, #c82333 100%); | ||||
|   color: white; | ||||
| } | ||||
|  | ||||
| .delete-button:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4); | ||||
| } | ||||
|  | ||||
| .delete-button:disabled { | ||||
|   opacity: 0.6; | ||||
|   cursor: not-allowed; | ||||
|   transform: none; | ||||
| } | ||||
|  | ||||
| .json-display { | ||||
|   background: #f8f9fa; | ||||
|   border: 1px solid #dee2e6; | ||||
|   border-radius: 8px; | ||||
|   padding: 20px; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .json-display h4 { | ||||
|   color: #495057; | ||||
|   margin-top: 0; | ||||
|   margin-bottom: 15px; | ||||
| } | ||||
|  | ||||
| .json-content { | ||||
|   background: #ffffff; | ||||
|   border: 1px solid #e9ecef; | ||||
|   border-radius: 4px; | ||||
|   padding: 15px; | ||||
|   font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; | ||||
|   font-size: 12px; | ||||
|   color: #495057; | ||||
|   max-height: 400px; | ||||
|   overflow-y: auto; | ||||
|   white-space: pre-wrap; | ||||
|   word-wrap: break-word; | ||||
| } | ||||
|  | ||||
| .box-stats { | ||||
|   background: rgba(102, 126, 234, 0.1); | ||||
|   border: 1px solid #dee2e6; | ||||
|   border-radius: 8px; | ||||
|   padding: 15px; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .box-stats p { | ||||
|   margin: 0; | ||||
|   color: #495057; | ||||
|   font-size: 14px; | ||||
| } | ||||
|  | ||||
| .card-grid { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | ||||
|   gap: 20px; | ||||
|   margin-top: 20px; | ||||
| } | ||||
|  | ||||
| .box-card-item { | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .card-info { | ||||
|   margin-top: 8px; | ||||
|   color: #6c757d; | ||||
|   font-size: 12px; | ||||
| } | ||||
|  | ||||
| .empty-box { | ||||
|   text-align: center; | ||||
|   padding: 40px 20px; | ||||
|   color: #6c757d; | ||||
|   background: #f8f9fa; | ||||
|   border-radius: 8px; | ||||
|   border: 1px solid #dee2e6; | ||||
| } | ||||
|  | ||||
| .empty-box p { | ||||
|   margin: 8px 0; | ||||
| } | ||||
|  | ||||
| .loading, | ||||
| .error { | ||||
|   text-align: center; | ||||
|   padding: 40px 20px; | ||||
|   color: #6c757d; | ||||
|   font-size: 16px; | ||||
| } | ||||
|  | ||||
| .error { | ||||
|   color: #dc3545; | ||||
|   background: #f8d7da; | ||||
|   border: 1px solid #f5c6cb; | ||||
|   border-radius: 8px; | ||||
| } | ||||
							
								
								
									
										170
									
								
								aicard-web-oauth/src/styles/CardList.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								aicard-web-oauth/src/styles/CardList.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,170 @@ | ||||
| .card-list-container { | ||||
|   min-height: 100vh; | ||||
|   background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%); | ||||
|   padding: 20px; | ||||
| } | ||||
|  | ||||
| .card-list-header { | ||||
|   text-align: center; | ||||
|   margin-bottom: 40px; | ||||
|   padding: 20px; | ||||
|   background: rgba(255, 255, 255, 0.05); | ||||
|   border-radius: 12px; | ||||
|   border: 1px solid rgba(255, 255, 255, 0.1); | ||||
| } | ||||
|  | ||||
| .card-list-header h1 { | ||||
|   color: #fff; | ||||
|   margin: 0 0 10px 0; | ||||
|   font-size: 2.5rem; | ||||
| } | ||||
|  | ||||
| .card-list-header p { | ||||
|   color: #999; | ||||
|   margin: 0; | ||||
|   font-size: 1.1rem; | ||||
| } | ||||
|  | ||||
| .card-list-grid { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | ||||
|   gap: 30px; | ||||
|   max-width: 1400px; | ||||
|   margin: 0 auto; | ||||
| } | ||||
|  | ||||
| .card-list-item { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   gap: 15px; | ||||
| } | ||||
|  | ||||
| /* Simple grid layout for user-page style */ | ||||
| .card-list-simple-grid { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | ||||
|   gap: 20px; | ||||
|   max-width: 1400px; | ||||
|   margin: 0 auto; | ||||
|   padding: 20px; | ||||
| } | ||||
|  | ||||
| .card-list-simple-item { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   gap: 10px; | ||||
| } | ||||
|  | ||||
| .info-button { | ||||
|   background: linear-gradient(135deg, #333 0%, #555 100%); | ||||
|   color: white; | ||||
|   border: 2px solid #666; | ||||
|   padding: 8px 16px; | ||||
|   border-radius: 6px; | ||||
|   cursor: pointer; | ||||
|   font-size: 0.9rem; | ||||
|   transition: all 0.3s ease; | ||||
|   width: 100%; | ||||
|   max-width: 240px; | ||||
| } | ||||
|  | ||||
| .info-button:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); | ||||
|   background: linear-gradient(135deg, #444 0%, #666 100%); | ||||
| } | ||||
|  | ||||
| .card-info-details { | ||||
|   background: rgba(255, 255, 255, 0.05); | ||||
|   border: 1px solid rgba(255, 255, 255, 0.1); | ||||
|   border-radius: 8px; | ||||
|   padding: 15px; | ||||
|   width: 100%; | ||||
|   max-width: 240px; | ||||
|   margin-top: 10px; | ||||
| } | ||||
|  | ||||
| .card-info-details p { | ||||
|   margin: 5px 0; | ||||
|   color: #ccc; | ||||
|   font-size: 0.85rem; | ||||
|   text-align: left; | ||||
| } | ||||
|  | ||||
| .card-info-details p strong { | ||||
|   color: #fff; | ||||
| } | ||||
|  | ||||
| .card-meta { | ||||
|   background: rgba(255, 255, 255, 0.05); | ||||
|   border: 1px solid rgba(255, 255, 255, 0.1); | ||||
|   border-radius: 8px; | ||||
|   padding: 15px; | ||||
|   width: 100%; | ||||
|   max-width: 250px; | ||||
| } | ||||
|  | ||||
| .card-meta p { | ||||
|   margin: 5px 0; | ||||
|   color: #ccc; | ||||
|   font-size: 0.9rem; | ||||
| } | ||||
|  | ||||
| .card-meta p:first-child { | ||||
|   font-weight: bold; | ||||
|   color: #fff; | ||||
| } | ||||
|  | ||||
| .card-description { | ||||
|   font-size: 0.85rem; | ||||
|   color: #999; | ||||
|   font-style: italic; | ||||
|   margin-top: 8px; | ||||
|   line-height: 1.4; | ||||
| } | ||||
|  | ||||
| .source-info { | ||||
|   font-size: 0.9rem; | ||||
|   color: #666; | ||||
|   margin-top: 5px; | ||||
| } | ||||
|  | ||||
| .loading, .error { | ||||
|   text-align: center; | ||||
|   padding: 40px; | ||||
|   color: #999; | ||||
|   font-size: 1.2rem; | ||||
| } | ||||
|  | ||||
| .error { | ||||
|   color: #ff4757; | ||||
| } | ||||
|  | ||||
| button { | ||||
|   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|   color: white; | ||||
|   border: none; | ||||
|   padding: 10px 20px; | ||||
|   border-radius: 5px; | ||||
|   cursor: pointer; | ||||
|   font-size: 1rem; | ||||
|   margin-top: 20px; | ||||
| } | ||||
|  | ||||
| button:hover { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); | ||||
| } | ||||
|  | ||||
| @media (max-width: 768px) { | ||||
|   .card-list-grid { | ||||
|     grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); | ||||
|     gap: 20px; | ||||
|   } | ||||
|    | ||||
|   .card-list-header h1 { | ||||
|     font-size: 2rem; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										172
									
								
								aicard-web-oauth/src/styles/CollectionAnalysis.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								aicard-web-oauth/src/styles/CollectionAnalysis.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| .collection-analysis { | ||||
|   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|   border-radius: 16px; | ||||
|   padding: 24px; | ||||
|   margin: 20px 0; | ||||
|   color: white; | ||||
|   box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); | ||||
| } | ||||
|  | ||||
| .collection-analysis h3 { | ||||
|   margin: 0 0 20px 0; | ||||
|   font-size: 1.5rem; | ||||
|   font-weight: 600; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .analysis-stats { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); | ||||
|   gap: 16px; | ||||
|   margin-bottom: 24px; | ||||
| } | ||||
|  | ||||
| .stat-card { | ||||
|   background: rgba(255, 255, 255, 0.15); | ||||
|   backdrop-filter: blur(10px); | ||||
|   border-radius: 12px; | ||||
|   padding: 16px; | ||||
|   text-align: center; | ||||
|   border: 1px solid rgba(255, 255, 255, 0.2); | ||||
| } | ||||
|  | ||||
| .stat-value { | ||||
|   font-size: 2rem; | ||||
|   font-weight: bold; | ||||
|   margin-bottom: 4px; | ||||
| } | ||||
|  | ||||
| .stat-label { | ||||
|   font-size: 0.9rem; | ||||
|   opacity: 0.8; | ||||
| } | ||||
|  | ||||
| .rarity-distribution { | ||||
|   margin-bottom: 24px; | ||||
| } | ||||
|  | ||||
| .rarity-distribution h4 { | ||||
|   margin: 0 0 16px 0; | ||||
|   font-size: 1.2rem; | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| .rarity-bars { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 8px; | ||||
| } | ||||
|  | ||||
| .rarity-bar { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 12px; | ||||
| } | ||||
|  | ||||
| .rarity-name { | ||||
|   min-width: 80px; | ||||
|   font-weight: 500; | ||||
|   text-transform: capitalize; | ||||
| } | ||||
|  | ||||
| .bar-container { | ||||
|   flex: 1; | ||||
|   height: 20px; | ||||
|   background: rgba(255, 255, 255, 0.2); | ||||
|   border-radius: 10px; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .bar { | ||||
|   height: 100%; | ||||
|   border-radius: 10px; | ||||
|   transition: width 0.3s ease; | ||||
| } | ||||
|  | ||||
| .bar-common { background: linear-gradient(90deg, #4CAF50, #45a049); } | ||||
| .bar-rare { background: linear-gradient(90deg, #2196F3, #1976D2); } | ||||
| .bar-epic { background: linear-gradient(90deg, #9C27B0, #7B1FA2); } | ||||
| .bar-legendary { background: linear-gradient(90deg, #FF9800, #F57C00); } | ||||
| .bar-mythic { background: linear-gradient(90deg, #F44336, #D32F2F); } | ||||
|  | ||||
| .rarity-count { | ||||
|   min-width: 40px; | ||||
|   text-align: right; | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| .recommendations { | ||||
|   background: rgba(255, 255, 255, 0.1); | ||||
|   border-radius: 12px; | ||||
|   padding: 16px; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .recommendations h4 { | ||||
|   margin: 0 0 12px 0; | ||||
|   font-size: 1.1rem; | ||||
| } | ||||
|  | ||||
| .recommendations ul { | ||||
|   margin: 0; | ||||
|   padding-left: 20px; | ||||
| } | ||||
|  | ||||
| .recommendations li { | ||||
|   margin-bottom: 8px; | ||||
|   line-height: 1.4; | ||||
| } | ||||
|  | ||||
| .refresh-analysis, | ||||
| .analyze-button, | ||||
| .retry-button { | ||||
|   background: rgba(255, 255, 255, 0.2); | ||||
|   border: 1px solid rgba(255, 255, 255, 0.3); | ||||
|   color: white; | ||||
|   border-radius: 8px; | ||||
|   padding: 12px 24px; | ||||
|   font-weight: 500; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
|   display: block; | ||||
|   margin: 0 auto; | ||||
| } | ||||
|  | ||||
| .refresh-analysis:hover, | ||||
| .analyze-button:hover, | ||||
| .retry-button:hover { | ||||
|   background: rgba(255, 255, 255, 0.3); | ||||
|   transform: translateY(-2px); | ||||
| } | ||||
|  | ||||
| .analysis-loading, | ||||
| .analysis-error, | ||||
| .analysis-empty { | ||||
|   text-align: center; | ||||
|   padding: 40px 20px; | ||||
| } | ||||
|  | ||||
| .loading-spinner { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   border: 3px solid rgba(255, 255, 255, 0.3); | ||||
|   border-top: 3px solid white; | ||||
|   border-radius: 50%; | ||||
|   animation: spin 1s linear infinite; | ||||
|   margin: 0 auto 16px; | ||||
| } | ||||
|  | ||||
| @keyframes spin { | ||||
|   0% { transform: rotate(0deg); } | ||||
|   100% { transform: rotate(360deg); } | ||||
| } | ||||
|  | ||||
| .analysis-error p { | ||||
|   color: #ffcdd2; | ||||
|   margin-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .analysis-empty p { | ||||
|   opacity: 0.8; | ||||
|   margin-bottom: 16px; | ||||
| } | ||||
							
								
								
									
										174
									
								
								aicard-web-oauth/src/styles/GachaAnimation.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								aicard-web-oauth/src/styles/GachaAnimation.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | ||||
| .gacha-container { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   background: rgba(0, 0, 0, 0.9); | ||||
|   z-index: 1000; | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .card-final { | ||||
|   position: relative; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .card-actions { | ||||
|   position: absolute; | ||||
|   bottom: -80px; | ||||
|   left: 50%; | ||||
|   transform: translateX(-50%); | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   gap: 10px; | ||||
| } | ||||
|  | ||||
| .save-button { | ||||
|   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|   color: white; | ||||
|   border: none; | ||||
|   padding: 10px 20px; | ||||
|   border-radius: 25px; | ||||
|   font-size: 14px; | ||||
|   font-weight: bold; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
|   box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); | ||||
| } | ||||
|  | ||||
| .save-button:hover:not(:disabled) { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); | ||||
| } | ||||
|  | ||||
| .save-button:disabled { | ||||
|   opacity: 0.6; | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .click-hint { | ||||
|   color: white; | ||||
|   font-size: 12px; | ||||
|   background: rgba(0, 0, 0, 0.7); | ||||
|   padding: 6px 12px; | ||||
|   border-radius: 15px; | ||||
|   animation: pulse 2s infinite; | ||||
| } | ||||
|  | ||||
| @keyframes pulse { | ||||
|   0%, 100% { opacity: 0.7; } | ||||
|   50% { opacity: 1; } | ||||
| } | ||||
|  | ||||
| .gacha-opening { | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .gacha-pack { | ||||
|   width: 200px; | ||||
|   height: 280px; | ||||
|   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|   border-radius: 16px; | ||||
|   position: relative; | ||||
|   box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); | ||||
| } | ||||
|  | ||||
| .pack-glow { | ||||
|   position: absolute; | ||||
|   top: -20px; | ||||
|   left: -20px; | ||||
|   right: -20px; | ||||
|   bottom: -20px; | ||||
|   background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%); | ||||
|   animation: glow-pulse 2s ease-in-out infinite; | ||||
| } | ||||
|  | ||||
| /* Effect variations */ | ||||
| .effect-normal { | ||||
|   background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%); | ||||
| } | ||||
|  | ||||
| .effect-rare { | ||||
|   background: radial-gradient(circle, rgba(74, 144, 226, 0.2) 0%, transparent 50%); | ||||
| } | ||||
|  | ||||
| .effect-kira { | ||||
|   background: radial-gradient(circle, rgba(255, 215, 0, 0.3) 0%, transparent 50%); | ||||
| } | ||||
|  | ||||
| .effect-kira::before { | ||||
|   content: ''; | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,0 60,40 100,50 60,60 50,100 40,60 0,50 40,40" fill="rgba(255,215,0,0.1)"/></svg>'); | ||||
|   background-size: 50px 50px; | ||||
|   animation: sparkle 3s linear infinite; | ||||
| } | ||||
|  | ||||
| .effect-unique { | ||||
|   background: radial-gradient(circle, rgba(255, 0, 255, 0.4) 0%, transparent 50%); | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .unique-effect { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| .unique-particles { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   background-image:  | ||||
|     radial-gradient(circle, #ff00ff 1px, transparent 1px), | ||||
|     radial-gradient(circle, #00ffff 1px, transparent 1px); | ||||
|   background-size: 50px 50px, 30px 30px; | ||||
|   background-position: 0 0, 25px 25px; | ||||
|   animation: particle-float 20s linear infinite; | ||||
| } | ||||
|  | ||||
| .unique-burst { | ||||
|   position: absolute; | ||||
|   top: 50%; | ||||
|   left: 50%; | ||||
|   width: 300px; | ||||
|   height: 300px; | ||||
|   transform: translate(-50%, -50%); | ||||
|   background: radial-gradient(circle, rgba(255, 0, 255, 0.8) 0%, transparent 70%); | ||||
|   animation: burst 1s ease-out; | ||||
| } | ||||
|  | ||||
| /* Animations */ | ||||
| @keyframes glow-pulse { | ||||
|   0%, 100% { opacity: 0.5; transform: scale(1); } | ||||
|   50% { opacity: 1; transform: scale(1.1); } | ||||
| } | ||||
|  | ||||
| @keyframes sparkle { | ||||
|   0% { transform: translateY(0) rotate(0deg); } | ||||
|   100% { transform: translateY(-100vh) rotate(360deg); } | ||||
| } | ||||
|  | ||||
| @keyframes particle-float { | ||||
|   0% { transform: translate(0, 0); } | ||||
|   100% { transform: translate(-50px, -100px); } | ||||
| } | ||||
|  | ||||
| @keyframes burst { | ||||
|   0% { transform: translate(-50%, -50%) scale(0); opacity: 1; } | ||||
|   100% { transform: translate(-50%, -50%) scale(3); opacity: 0; } | ||||
| } | ||||
							
								
								
									
										219
									
								
								aicard-web-oauth/src/styles/GachaStats.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								aicard-web-oauth/src/styles/GachaStats.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,219 @@ | ||||
| .gacha-stats { | ||||
|   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|   border-radius: 16px; | ||||
|   padding: 24px; | ||||
|   margin: 20px 0; | ||||
|   color: white; | ||||
|   box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); | ||||
| } | ||||
|  | ||||
| .gacha-stats h3 { | ||||
|   margin: 0 0 20px 0; | ||||
|   font-size: 1.5rem; | ||||
|   font-weight: 600; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .stats-overview { | ||||
|   margin-bottom: 24px; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .overview-card { | ||||
|   background: rgba(255, 255, 255, 0.15); | ||||
|   backdrop-filter: blur(10px); | ||||
|   border-radius: 12px; | ||||
|   padding: 20px; | ||||
|   border: 1px solid rgba(255, 255, 255, 0.2); | ||||
|   display: inline-block; | ||||
|   min-width: 200px; | ||||
| } | ||||
|  | ||||
| .overview-value { | ||||
|   font-size: 2.5rem; | ||||
|   font-weight: bold; | ||||
|   margin-bottom: 8px; | ||||
| } | ||||
|  | ||||
| .overview-label { | ||||
|   font-size: 1rem; | ||||
|   opacity: 0.9; | ||||
| } | ||||
|  | ||||
| .rarity-stats { | ||||
|   margin-bottom: 24px; | ||||
| } | ||||
|  | ||||
| .rarity-stats h4 { | ||||
|   margin: 0 0 16px 0; | ||||
|   font-size: 1.2rem; | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| .rarity-grid { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); | ||||
|   gap: 12px; | ||||
| } | ||||
|  | ||||
| .rarity-stat { | ||||
|   background: rgba(255, 255, 255, 0.15); | ||||
|   backdrop-filter: blur(10px); | ||||
|   border-radius: 12px; | ||||
|   padding: 16px; | ||||
|   text-align: center; | ||||
|   border: 1px solid rgba(255, 255, 255, 0.2); | ||||
|   position: relative; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .rarity-stat::before { | ||||
|   content: ''; | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   height: 3px; | ||||
|   background: var(--rarity-color); | ||||
| } | ||||
|  | ||||
| .rarity-stat.rarity-common { --rarity-color: #4CAF50; } | ||||
| .rarity-stat.rarity-rare { --rarity-color: #2196F3; } | ||||
| .rarity-stat.rarity-epic { --rarity-color: #9C27B0; } | ||||
| .rarity-stat.rarity-legendary { --rarity-color: #FF9800; } | ||||
| .rarity-stat.rarity-mythic { --rarity-color: #F44336; } | ||||
|  | ||||
| .rarity-count { | ||||
|   font-size: 1.8rem; | ||||
|   font-weight: bold; | ||||
|   margin-bottom: 4px; | ||||
| } | ||||
|  | ||||
| .rarity-name { | ||||
|   font-size: 0.9rem; | ||||
|   opacity: 0.9; | ||||
|   text-transform: capitalize; | ||||
|   margin-bottom: 4px; | ||||
| } | ||||
|  | ||||
| .success-rate { | ||||
|   font-size: 0.8rem; | ||||
|   opacity: 0.7; | ||||
|   background: rgba(255, 255, 255, 0.1); | ||||
|   border-radius: 4px; | ||||
|   padding: 2px 6px; | ||||
|   display: inline-block; | ||||
| } | ||||
|  | ||||
| .recent-activity { | ||||
|   background: rgba(255, 255, 255, 0.1); | ||||
|   border-radius: 12px; | ||||
|   padding: 16px; | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .recent-activity h4 { | ||||
|   margin: 0 0 12px 0; | ||||
|   font-size: 1.1rem; | ||||
| } | ||||
|  | ||||
| .activity-list { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 8px; | ||||
| } | ||||
|  | ||||
| .activity-item { | ||||
|   background: rgba(255, 255, 255, 0.05); | ||||
|   border-radius: 8px; | ||||
|   padding: 12px; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .activity-time { | ||||
|   font-size: 0.8rem; | ||||
|   opacity: 0.7; | ||||
|   min-width: 120px; | ||||
| } | ||||
|  | ||||
| .activity-details { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 8px; | ||||
|   flex: 1; | ||||
|   justify-content: flex-end; | ||||
| } | ||||
|  | ||||
| .card-rarity { | ||||
|   padding: 2px 8px; | ||||
|   border-radius: 4px; | ||||
|   font-size: 0.75rem; | ||||
|   font-weight: 500; | ||||
|   text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .card-rarity.rarity-common { background: #4CAF50; } | ||||
| .card-rarity.rarity-rare { background: #2196F3; } | ||||
| .card-rarity.rarity-epic { background: #9C27B0; } | ||||
| .card-rarity.rarity-legendary { background: #FF9800; } | ||||
| .card-rarity.rarity-mythic { background: #F44336; } | ||||
|  | ||||
| .card-name { | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| .refresh-stats, | ||||
| .load-stats-button, | ||||
| .retry-button { | ||||
|   background: rgba(255, 255, 255, 0.2); | ||||
|   border: 1px solid rgba(255, 255, 255, 0.3); | ||||
|   color: white; | ||||
|   border-radius: 8px; | ||||
|   padding: 12px 24px; | ||||
|   font-weight: 500; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
|   display: block; | ||||
|   margin: 0 auto; | ||||
| } | ||||
|  | ||||
| .refresh-stats:hover, | ||||
| .load-stats-button:hover, | ||||
| .retry-button:hover { | ||||
|   background: rgba(255, 255, 255, 0.3); | ||||
|   transform: translateY(-2px); | ||||
| } | ||||
|  | ||||
| .stats-loading, | ||||
| .stats-error, | ||||
| .stats-empty { | ||||
|   text-align: center; | ||||
|   padding: 40px 20px; | ||||
| } | ||||
|  | ||||
| .loading-spinner { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   border: 3px solid rgba(255, 255, 255, 0.3); | ||||
|   border-top: 3px solid white; | ||||
|   border-radius: 50%; | ||||
|   animation: spin 1s linear infinite; | ||||
|   margin: 0 auto 16px; | ||||
| } | ||||
|  | ||||
| @keyframes spin { | ||||
|   0% { transform: rotate(0deg); } | ||||
|   100% { transform: rotate(360deg); } | ||||
| } | ||||
|  | ||||
| .stats-error p { | ||||
|   color: #ffcdd2; | ||||
|   margin-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .stats-empty p { | ||||
|   opacity: 0.8; | ||||
|   margin-bottom: 16px; | ||||
| } | ||||
							
								
								
									
										243
									
								
								aicard-web-oauth/src/styles/Login.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										243
									
								
								aicard-web-oauth/src/styles/Login.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,243 @@ | ||||
| .login-overlay { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   background: rgba(0, 0, 0, 0.8); | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   z-index: 1000; | ||||
|   backdrop-filter: blur(5px); | ||||
| } | ||||
|  | ||||
| .login-modal { | ||||
|   background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); | ||||
|   border: 1px solid #444; | ||||
|   border-radius: 16px; | ||||
|   padding: 40px; | ||||
|   max-width: 450px; | ||||
|   width: 90%; | ||||
|   box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); | ||||
| } | ||||
|  | ||||
| .login-mode-selector { | ||||
|   display: flex; | ||||
|   margin-bottom: 24px; | ||||
|   background: rgba(255, 255, 255, 0.05); | ||||
|   border-radius: 8px; | ||||
|   padding: 4px; | ||||
| } | ||||
|  | ||||
| .mode-button { | ||||
|   flex: 1; | ||||
|   padding: 12px 16px; | ||||
|   border: none; | ||||
|   background: transparent; | ||||
|   color: #ccc; | ||||
|   border-radius: 6px; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| .mode-button.active { | ||||
|   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|   color: white; | ||||
|   box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); | ||||
| } | ||||
|  | ||||
| .mode-button:hover:not(.active) { | ||||
|   background: rgba(255, 255, 255, 0.1); | ||||
|   color: white; | ||||
| } | ||||
|  | ||||
| .oauth-login { | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .oauth-info { | ||||
|   margin-bottom: 24px; | ||||
|   padding: 20px; | ||||
|   background: rgba(102, 126, 234, 0.1); | ||||
|   border-radius: 12px; | ||||
|   border: 1px solid rgba(102, 126, 234, 0.3); | ||||
| } | ||||
|  | ||||
| .oauth-info h3 { | ||||
|   margin: 0 0 12px 0; | ||||
|   font-size: 18px; | ||||
|   color: #667eea; | ||||
| } | ||||
|  | ||||
| .oauth-info p { | ||||
|   margin: 0; | ||||
|   font-size: 14px; | ||||
|   line-height: 1.5; | ||||
|   opacity: 0.9; | ||||
| } | ||||
|  | ||||
| .oauth-login-button { | ||||
|   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|   border: none; | ||||
|   color: white; | ||||
|   padding: 16px 32px; | ||||
|   border-radius: 12px; | ||||
|   font-size: 16px; | ||||
|   font-weight: 600; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
|   box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3); | ||||
| } | ||||
|  | ||||
| .oauth-login-button:hover:not(:disabled) { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); | ||||
| } | ||||
|  | ||||
| .oauth-login-button:disabled { | ||||
|   opacity: 0.7; | ||||
|   cursor: not-allowed; | ||||
|   transform: none; | ||||
| } | ||||
|  | ||||
| .login-modal h2 { | ||||
|   margin: 0 0 30px 0; | ||||
|   font-size: 28px; | ||||
|   text-align: center; | ||||
|   background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%); | ||||
|   -webkit-background-clip: text; | ||||
|   -webkit-text-fill-color: transparent; | ||||
|   background-clip: text; | ||||
| } | ||||
|  | ||||
| .form-group { | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .form-group label { | ||||
|   display: block; | ||||
|   margin-bottom: 8px; | ||||
|   color: #ccc; | ||||
|   font-size: 14px; | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| .form-group input { | ||||
|   width: 100%; | ||||
|   padding: 12px 16px; | ||||
|   background: rgba(255, 255, 255, 0.1); | ||||
|   border: 1px solid #444; | ||||
|   border-radius: 8px; | ||||
|   color: white; | ||||
|   font-size: 16px; | ||||
|   transition: all 0.3s ease; | ||||
| } | ||||
|  | ||||
| .form-group input:focus { | ||||
|   outline: none; | ||||
|   border-color: #fff700; | ||||
|   background: rgba(255, 255, 255, 0.15); | ||||
|   box-shadow: 0 0 0 2px rgba(255, 247, 0, 0.2); | ||||
| } | ||||
|  | ||||
| .form-group input:disabled { | ||||
|   opacity: 0.5; | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .form-group small { | ||||
|   display: block; | ||||
|   margin-top: 6px; | ||||
|   color: #888; | ||||
|   font-size: 12px; | ||||
| } | ||||
|  | ||||
| .form-group small a { | ||||
|   color: #fff700; | ||||
|   text-decoration: none; | ||||
| } | ||||
|  | ||||
| .form-group small a:hover { | ||||
|   text-decoration: underline; | ||||
| } | ||||
|  | ||||
| .error-message { | ||||
|   background: rgba(255, 71, 87, 0.1); | ||||
|   border: 1px solid rgba(255, 71, 87, 0.3); | ||||
|   border-radius: 8px; | ||||
|   padding: 12px; | ||||
|   margin-bottom: 20px; | ||||
|   color: #ff4757; | ||||
|   font-size: 14px; | ||||
| } | ||||
|  | ||||
| .button-group { | ||||
|   display: flex; | ||||
|   gap: 12px; | ||||
|   margin-top: 30px; | ||||
| } | ||||
|  | ||||
| .login-button, | ||||
| .cancel-button { | ||||
|   flex: 1; | ||||
|   padding: 14px 24px; | ||||
|   border: none; | ||||
|   border-radius: 8px; | ||||
|   font-size: 16px; | ||||
|   font-weight: bold; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.3s ease; | ||||
| } | ||||
|  | ||||
| .login-button { | ||||
|   background: linear-gradient(135deg, #fff700 0%, #ffd700 100%); | ||||
|   color: #000; | ||||
| } | ||||
|  | ||||
| .login-button:hover:not(:disabled) { | ||||
|   transform: translateY(-2px); | ||||
|   box-shadow: 0 6px 20px rgba(255, 247, 0, 0.4); | ||||
| } | ||||
|  | ||||
| .login-button:disabled { | ||||
|   opacity: 0.5; | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .cancel-button { | ||||
|   background: rgba(255, 255, 255, 0.1); | ||||
|   color: white; | ||||
|   border: 1px solid #444; | ||||
| } | ||||
|  | ||||
| .cancel-button:hover:not(:disabled) { | ||||
|   background: rgba(255, 255, 255, 0.15); | ||||
|   border-color: #666; | ||||
| } | ||||
|  | ||||
| .login-info { | ||||
|   margin-top: 30px; | ||||
|   padding-top: 20px; | ||||
|   border-top: 1px solid #333; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .login-info p { | ||||
|   color: #888; | ||||
|   font-size: 14px; | ||||
|   line-height: 1.6; | ||||
|   margin: 0; | ||||
| } | ||||
|  | ||||
| .dev-notice { | ||||
|   background: rgba(255, 193, 7, 0.1); | ||||
|   border: 1px solid rgba(255, 193, 7, 0.3); | ||||
|   border-radius: 6px; | ||||
|   padding: 8px 12px; | ||||
|   margin: 10px 0; | ||||
|   color: #ffc107; | ||||
|   font-size: 12px; | ||||
|   text-align: center; | ||||
| } | ||||
							
								
								
									
										24
									
								
								aicard-web-oauth/src/types/card.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								aicard-web-oauth/src/types/card.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| export enum CardRarity { | ||||
|   NORMAL = "normal", | ||||
|   RARE = "rare", | ||||
|   SUPER_RARE = "super_rare", | ||||
|   KIRA = "kira", | ||||
|   UNIQUE = "unique" | ||||
| } | ||||
|  | ||||
| export interface Card { | ||||
|   id: number; | ||||
|   cp: number; | ||||
|   status: CardRarity; | ||||
|   skill?: string; | ||||
|   owner_did: string; | ||||
|   obtained_at: string; | ||||
|   is_unique: boolean; | ||||
|   unique_id?: string; | ||||
| } | ||||
|  | ||||
| export interface CardDrawResult { | ||||
|   card: Card; | ||||
|   is_new: boolean; | ||||
|   animation_type: string; | ||||
| } | ||||
							
								
								
									
										141
									
								
								aicard-web-oauth/src/utils/oauth-endpoints.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								aicard-web-oauth/src/utils/oauth-endpoints.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| /** | ||||
|  * OAuth dynamic endpoint handlers | ||||
|  */ | ||||
| import { OAuthKeyManager, generateClientMetadata } from './oauth-keys'; | ||||
|  | ||||
| export class OAuthEndpointHandler { | ||||
|   /** | ||||
|    * Initialize OAuth endpoint handlers | ||||
|    */ | ||||
|   static init() { | ||||
|     // Intercept requests to client-metadata.json | ||||
|     this.setupClientMetadataHandler(); | ||||
|      | ||||
|     // Intercept requests to .well-known/jwks.json | ||||
|     this.setupJWKSHandler(); | ||||
|   } | ||||
|  | ||||
|   private static setupClientMetadataHandler() { | ||||
|     // Override fetch for client-metadata.json requests | ||||
|     const originalFetch = window.fetch; | ||||
|      | ||||
|     window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { | ||||
|       const url = typeof input === 'string' ? input : input.toString(); | ||||
|        | ||||
|       // Only intercept local OAuth endpoints | ||||
|       try { | ||||
|         const urlObj = new URL(url, window.location.origin); | ||||
|          | ||||
|         // Only intercept requests to the same origin | ||||
|         if (urlObj.origin !== window.location.origin) { | ||||
|           // Pass through external API calls unchanged | ||||
|           return originalFetch(input, init); | ||||
|         } | ||||
|          | ||||
|         // Handle local OAuth endpoints | ||||
|         if (urlObj.pathname.endsWith('/client-metadata.json')) { | ||||
|           const metadata = generateClientMetadata(); | ||||
|           return new Response(JSON.stringify(metadata, null, 2), { | ||||
|             headers: { | ||||
|               'Content-Type': 'application/json', | ||||
|               'Access-Control-Allow-Origin': '*' | ||||
|             } | ||||
|           }); | ||||
|         } | ||||
|          | ||||
|         if (urlObj.pathname.endsWith('/.well-known/jwks.json')) { | ||||
|           try { | ||||
|             const jwks = await OAuthKeyManager.getJWKS(); | ||||
|             return new Response(JSON.stringify(jwks, null, 2), { | ||||
|               headers: { | ||||
|                 'Content-Type': 'application/json', | ||||
|                 'Access-Control-Allow-Origin': '*' | ||||
|               } | ||||
|             }); | ||||
|           } catch (error) { | ||||
|             console.error('Failed to generate JWKS:', error); | ||||
|             return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), { | ||||
|               status: 500, | ||||
|               headers: { 'Content-Type': 'application/json' } | ||||
|             }); | ||||
|           } | ||||
|         } | ||||
|       } catch (e) { | ||||
|         // If URL parsing fails, pass through to original fetch | ||||
|         console.debug('URL parsing failed, passing through:', e); | ||||
|       } | ||||
|        | ||||
|       // Pass through all other requests | ||||
|       return originalFetch(input, init); | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   private static setupJWKSHandler() { | ||||
|     // This is handled in the fetch override above | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Generate a proper client assertion JWT for token requests | ||||
|    */ | ||||
|   static async generateClientAssertion(tokenEndpoint: string): Promise<string> { | ||||
|     const now = Math.floor(Date.now() / 1000); | ||||
|     const clientId = generateClientMetadata().client_id; | ||||
|  | ||||
|     const header = { | ||||
|       alg: 'ES256', | ||||
|       typ: 'JWT', | ||||
|       kid: 'ai-card-oauth-key-1' | ||||
|     }; | ||||
|  | ||||
|     const payload = { | ||||
|       iss: clientId, | ||||
|       sub: clientId, | ||||
|       aud: tokenEndpoint, | ||||
|       iat: now, | ||||
|       exp: now + 300, // 5 minutes | ||||
|       jti: crypto.randomUUID() | ||||
|     }; | ||||
|  | ||||
|     return await OAuthKeyManager.signJWT(header, payload); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Service Worker alternative for intercepting requests | ||||
|  * (This is a more robust solution for production) | ||||
|  */ | ||||
| export function registerOAuthServiceWorker() { | ||||
|   if ('serviceWorker' in navigator) { | ||||
|     const swCode = ` | ||||
|       self.addEventListener('fetch', (event) => { | ||||
|         const url = new URL(event.request.url); | ||||
|          | ||||
|         if (url.pathname.endsWith('/client-metadata.json')) { | ||||
|           event.respondWith( | ||||
|             new Response(JSON.stringify({ | ||||
|               client_id: url.origin + '/client-metadata.json', | ||||
|               client_name: 'ai.card', | ||||
|               client_uri: url.origin, | ||||
|               redirect_uris: [url.origin + '/oauth/callback'], | ||||
|               response_types: ['code'], | ||||
|               grant_types: ['authorization_code', 'refresh_token'], | ||||
|               token_endpoint_auth_method: 'private_key_jwt', | ||||
|               scope: 'atproto transition:generic', | ||||
|               subject_type: 'public', | ||||
|               application_type: 'web', | ||||
|               dpop_bound_access_tokens: true, | ||||
|               jwks_uri: url.origin + '/.well-known/jwks.json' | ||||
|             }, null, 2), { | ||||
|               headers: { 'Content-Type': 'application/json' } | ||||
|             }) | ||||
|           ); | ||||
|         } | ||||
|       }); | ||||
|     `; | ||||
|      | ||||
|     const blob = new Blob([swCode], { type: 'application/javascript' }); | ||||
|     const swUrl = URL.createObjectURL(blob); | ||||
|      | ||||
|     navigator.serviceWorker.register(swUrl).catch(console.error); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										204
									
								
								aicard-web-oauth/src/utils/oauth-keys.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										204
									
								
								aicard-web-oauth/src/utils/oauth-keys.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,204 @@ | ||||
| /** | ||||
|  * OAuth JWKS key generation and management | ||||
|  */ | ||||
|  | ||||
| export interface JWK { | ||||
|   kty: string; | ||||
|   crv: string; | ||||
|   x: string; | ||||
|   y: string; | ||||
|   d?: string; | ||||
|   use: string; | ||||
|   kid: string; | ||||
|   alg: string; | ||||
| } | ||||
|  | ||||
| export interface JWKS { | ||||
|   keys: JWK[]; | ||||
| } | ||||
|  | ||||
| export class OAuthKeyManager { | ||||
|   private static keyPair: CryptoKeyPair | null = null; | ||||
|   private static jwks: JWKS | null = null; | ||||
|  | ||||
|   /** | ||||
|    * Generate or retrieve existing ECDSA key pair for OAuth | ||||
|    */ | ||||
|   static async getKeyPair(): Promise<CryptoKeyPair> { | ||||
|     if (this.keyPair) { | ||||
|       return this.keyPair; | ||||
|     } | ||||
|  | ||||
|     // Try to load from localStorage first | ||||
|     const storedKey = localStorage.getItem('oauth_private_key'); | ||||
|     if (storedKey) { | ||||
|       try { | ||||
|         const keyData = JSON.parse(storedKey); | ||||
|         this.keyPair = await this.importKeyPair(keyData); | ||||
|         return this.keyPair; | ||||
|       } catch (error) { | ||||
|         console.warn('Failed to load stored key, generating new one:', error); | ||||
|         localStorage.removeItem('oauth_private_key'); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Generate new key pair | ||||
|     this.keyPair = await window.crypto.subtle.generateKey( | ||||
|       { | ||||
|         name: 'ECDSA', | ||||
|         namedCurve: 'P-256', | ||||
|       }, | ||||
|       true, // extractable | ||||
|       ['sign', 'verify'] | ||||
|     ); | ||||
|  | ||||
|     // Store private key for persistence | ||||
|     await this.storeKeyPair(this.keyPair); | ||||
|  | ||||
|     return this.keyPair; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get JWKS (JSON Web Key Set) for public key distribution | ||||
|    */ | ||||
|   static async getJWKS(): Promise<JWKS> { | ||||
|     if (this.jwks) { | ||||
|       return this.jwks; | ||||
|     } | ||||
|  | ||||
|     const keyPair = await this.getKeyPair(); | ||||
|     const publicKey = await window.crypto.subtle.exportKey('jwk', keyPair.publicKey); | ||||
|  | ||||
|     this.jwks = { | ||||
|       keys: [ | ||||
|         { | ||||
|           kty: publicKey.kty!, | ||||
|           crv: publicKey.crv!, | ||||
|           x: publicKey.x!, | ||||
|           y: publicKey.y!, | ||||
|           use: 'sig', | ||||
|           kid: 'ai-card-oauth-key-1', | ||||
|           alg: 'ES256' | ||||
|         } | ||||
|       ] | ||||
|     }; | ||||
|  | ||||
|     return this.jwks; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Sign a JWT with the private key | ||||
|    */ | ||||
|   static async signJWT(header: any, payload: any): Promise<string> { | ||||
|     const keyPair = await this.getKeyPair(); | ||||
|      | ||||
|     const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, ''); | ||||
|     const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, ''); | ||||
|     const message = `${headerB64}.${payloadB64}`; | ||||
|      | ||||
|     const signature = await window.crypto.subtle.sign( | ||||
|       { name: 'ECDSA', hash: 'SHA-256' }, | ||||
|       keyPair.privateKey, | ||||
|       new TextEncoder().encode(message) | ||||
|     ); | ||||
|      | ||||
|     const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature))) | ||||
|       .replace(/\+/g, '-') | ||||
|       .replace(/\//g, '_') | ||||
|       .replace(/=/g, ''); | ||||
|      | ||||
|     return `${message}.${signatureB64}`; | ||||
|   } | ||||
|  | ||||
|   private static async storeKeyPair(keyPair: CryptoKeyPair): Promise<void> { | ||||
|     try { | ||||
|       const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey); | ||||
|       localStorage.setItem('oauth_private_key', JSON.stringify(privateKey)); | ||||
|     } catch (error) { | ||||
|       console.error('Failed to store private key:', error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private static async importKeyPair(keyData: any): Promise<CryptoKeyPair> { | ||||
|     const privateKey = await window.crypto.subtle.importKey( | ||||
|       'jwk', | ||||
|       keyData, | ||||
|       { name: 'ECDSA', namedCurve: 'P-256' }, | ||||
|       true, | ||||
|       ['sign'] | ||||
|     ); | ||||
|  | ||||
|     // Derive public key from private key | ||||
|     const publicKeyData = { ...keyData }; | ||||
|     delete publicKeyData.d; // Remove private component | ||||
|  | ||||
|     const publicKey = await window.crypto.subtle.importKey( | ||||
|       'jwk', | ||||
|       publicKeyData, | ||||
|       { name: 'ECDSA', namedCurve: 'P-256' }, | ||||
|       true, | ||||
|       ['verify'] | ||||
|     ); | ||||
|  | ||||
|     return { privateKey, publicKey }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Clear stored keys (for testing/reset) | ||||
|    */ | ||||
|   static clearKeys(): void { | ||||
|     localStorage.removeItem('oauth_private_key'); | ||||
|     this.keyPair = null; | ||||
|     this.jwks = null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Generate dynamic client metadata based on current URL | ||||
|  */ | ||||
| export function generateClientMetadata(): any { | ||||
|   const origin = window.location.origin; | ||||
|   const clientId = `${origin}/client-metadata.json`; | ||||
|  | ||||
|   // Use static production metadata for xxxcard.syui.ai | ||||
|   if (origin === 'https://xxxcard.syui.ai') { | ||||
|     return { | ||||
|       client_id: 'https://xxxcard.syui.ai/client-metadata.json', | ||||
|       client_name: 'ai.card', | ||||
|       client_uri: 'https://xxxcard.syui.ai', | ||||
|       logo_uri: 'https://xxxcard.syui.ai/favicon.ico', | ||||
|       tos_uri: 'https://xxxcard.syui.ai/terms', | ||||
|       policy_uri: 'https://xxxcard.syui.ai/privacy', | ||||
|       redirect_uris: ['https://xxxcard.syui.ai/oauth/callback'], | ||||
|       response_types: ['code'], | ||||
|       grant_types: ['authorization_code', 'refresh_token'], | ||||
|       token_endpoint_auth_method: 'private_key_jwt', | ||||
|       token_endpoint_auth_signing_alg: 'ES256', | ||||
|       scope: 'atproto transition:generic', | ||||
|       subject_type: 'public', | ||||
|       application_type: 'web', | ||||
|       dpop_bound_access_tokens: true, | ||||
|       jwks_uri: 'https://xxxcard.syui.ai/.well-known/jwks.json' | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   // Dynamic metadata for development | ||||
|   return { | ||||
|     client_id: clientId, | ||||
|     client_name: 'ai.card', | ||||
|     client_uri: origin, | ||||
|     logo_uri: `${origin}/favicon.ico`, | ||||
|     tos_uri: `${origin}/terms`, | ||||
|     policy_uri: `${origin}/privacy`, | ||||
|     redirect_uris: [`${origin}/oauth/callback`], | ||||
|     response_types: ['code'], | ||||
|     grant_types: ['authorization_code', 'refresh_token'], | ||||
|     token_endpoint_auth_method: 'private_key_jwt', | ||||
|     token_endpoint_auth_signing_alg: 'ES256', | ||||
|     scope: 'atproto transition:generic', | ||||
|     subject_type: 'public', | ||||
|     application_type: 'web', | ||||
|     dpop_bound_access_tokens: true, | ||||
|     jwks_uri: `${origin}/.well-known/jwks.json` | ||||
|   }; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user