관리자 페이지의 UX 를 더 개선해보자!
개선해보자!
- 각 카드에 업로드 날짜도 표시하기 ✔
- 로딩 애니메이션 가운데 정렬 ✔
- 마우스 금지 표시 해제 ✔
- 페이지네이션 ✔
- 이미 MongoDB에 저장된 Subtitle 에 대하여 다운로드 버튼 비활성화 ✔
- 유튜브 채널 이름 보여주기 ✔
- 유튜브 채널 링크 바로가기 ✔ (Go To
https://www.youtube.com/channel/[채널ID]
)
로딩 애니메이션페이지네이션, MongoDB 저장 여부에 따른 버튼 활성화/비활성화
관리자 페이지라고 하더라도, 더 쉽게 볼 수 있고 사용자의 접근성을 높일 수 있도록 pagination 을 적용하기로 했습니다. 데이터베이스에 저장된 링크 수가 대량일지는 저희도 모르기 때문입니다!
- 한 페이지에 페이지 링크는 30개로 보여준다.
- 이전 버튼, 다음 버튼이 존재한다.
- 첫 페이지로 가는 버튼, 마지막 페이지로 버튼이 존재한다.
- 한 번에 총 5 페이지에 대한 버튼만 보여준다.
우리 프로젝트에서 페이지네이션을 구현하려면 서버 측 코드(app.py)와 클라이언트 측 코드(HTML 템플릿) 모두 수정해야 합니다. 서버 측에서는 데이터를 페이지 단위로 나눠서 전송해야 하고, 클라이언트 측에서는 사용자가 페이지를 넘길 수 있는 인터페이스를 제공해야 합니다.
또한 페이지네이션 로직을 POST
(제출) 요청과 별개로 실행되게 해야 합니다. 그 이유는 페이지네이션은 일반적으로 데이터를 조회하는 목적의 기능 이며, HTTP 요청 메서드 (POST
, GET
등)에 관계 없이 사용자가 웹 페이지에서 데이터를 탐색하고 검색할 수 있어야하기 때문입니다.
사용자가 페이지를 선택하면 페이지 번호 및 관련된 데이터가 서버로 전달되고, 서버는 해당 페이지의 비디오 목록을 반환하여 페이지네이션을 제공합니다.
초기 페이지 번호는 1로 설정됩니다.
사용자가 채널 ID를 입력하고 검색을 요청하면, POST 메서드가 사용되어 서버로 요청이 전송됩니다. 서버는 사용자가 입력한 채널 ID를 가져옵니다.
서버는 현재 페이지 번호를 GET
(조회) 요청에서 가져옵니다.
page = request.args.get('page', 1, type=int) # 페이지 번호
총 페이지 수(total_pages
) = "총 동영상 수 / 페이지당 동영상 수 (per_page
)"
total_pages = (total_videos + per_page - 1) // per_page
@app.route('/', methods=['GET', 'POST'])
def index():
response_data = {'videos': [], 'channel_id': '', 'error': '',
'video_cnt': 0, 'published_at': '', 'channel_name': '',
'channel_link': '', 'total_pages': 0, 'current_page': 1} # 기본값으로 current_page를 1로 설정
# GET 요청에서 채널 ID 가져오기
channel_id = request.args.get('channel_id', '')
if request.method == 'POST':
channel_id = request.form['channel_id']
# 채널 ID 유효성 검사
if not youtube_data.validate_channel_id(channel_id):
response_data['error'] = 'Invalid Channel ID. Enter again please!'
return render_template('index.html', data=response_data)
try:
response_data['channel_id'] = channel_id
exists_in_mongo = videos_db_query.check_channel_id_in_mongodb(channel_id)
exists_in_sqlite = videos_db_query.check_channel_id_in_sqlite(channel_id)
if not exists_in_mongo and not exists_in_sqlite:
youtube_data.update_db(channel_id)
elif exists_in_sqlite:
response_data['videos'] = videos_db_query.innerjoin_by_channel_id(channel_id)
response_data['video_cnt'] = videos_db_query.get_video_count(channel_id)
response_data['channel_name'] = videos_db_query.get_channel_name(channel_id)
response_data['channel_link'] = videos_db_query.get_channel_link(channel_id)
except Exception as e:
response_data['error'] = f'An error occurred: {str(e)}'
return render_template('index.html', data=response_data)
# 페이지네이션 처리는 POST와 무관하게 수행
page = request.args.get('page', 1, type=int) # 페이지 번호 # HTTP GET
per_page = 30 # 한 페이지당 비디오 수
if channel_id:
response_data['channel_id'] = channel_id
total_videos = videos_db_query.get_video_count(channel_id) # 전체 동영상 개수
total_pages = (total_videos + per_page - 1) // per_page # 총 페이지 수는 전체 동영상 수를 페이지당 동영상 수로 나누어 계산
# 현재 페이지에 대한 정보와 함께, 채널 이름과 링크도 함께 업데이트하여 response_data에 저장
response_data['videos'] = videos_db_query.get_videos_by_page(channel_id, page, per_page)
response_data['video_cnt'] = videos_db_query.get_video_count(channel_id)
response_data['total_pages'] = total_pages
response_data['current_page'] = page
channel_name = videos_db_query.get_channel_name(channel_id)
if channel_name:
response_data['channel_name'] = channel_name # 채널 이름 설정
response_data['channel_link'] = videos_db_query.get_channel_link(channel_id)
return render_template('index.html', data=response_data)
아래는 get_videos_by_page()
함수입니다.
LIMIT
및 OFFSET
을 사용하여 특정 페이지의 비디오만 선택하도록 제한합니다.
LIMIT
는 페이지당 비디오 수(per_page) 를 나타내며,
LIMIT
절은 쿼리 결과에서 반환할 행(row)의 최대 수를 지정하는 데 사용LIMIT 10
은 쿼리 결과에서 최대 10개의 행만 반환OFFSET
은 페이지 번호(page) 에 따라 오프셋을 계산하여 해당 페이지의 비디오를 선택합니다.
OFFSET
절은 쿼리 결과에서 건너뛸 행의 수를 지정하는 데 사용OFFSET 20
은 처음 20개의 결과를 건너뛰고 그 이후의 결과를 반환# 특정 페이지의 비디오만 조회하는 쿼리
def get_videos_by_page(channel_id, page, per_page):
with sqlite3.connect('videos.db') as connect:
cursor = connect.cursor()
offset = (page - 1) * per_page
query = """
SELECT video.vid, video.video_id, video.title, video.link, video.published_at
FROM video
INNER JOIN channel ON video.cid = channel.cid
WHERE channel.channel_id = ?
ORDER BY video.published_at DESC
LIMIT ? OFFSET ?
"""
cursor.execute(query, (channel_id, per_page, offset))
rows = cursor.fetchall()
return [{"vid": row[0], "video_id": row[1], "title": row[2], "link": row[3], "published_at": row[4]} for row in rows]
아래는 index.html
의 페이지네이션 관련 코드입니다.
First
, Prev
, 페이지 번호
, Next
, Last
와 같은 페이지 이동 버튼을 제공합니다.
현재 페이지 주변의 페이지 번호들 (최대 5개) 을 표시하고, 현재 페이지는 강조 표시됩니다.
각 페이지 번호에는 해당 페이지로 이동할 수 있는 링크가 포함됩니다.
"First" 버튼
href
속성을 통해 해당 버튼을 클릭하면 /?page=1
로 이동하게 됩니다.data.channel_id
), 해당 ID를 페이지로 전달하기 위해 &channel_id=
와 함께 추가됩니다."Prev" 버튼
{% if data.current_page > 1 %}
구문을 통해 현재 페이지가 1보다 큰 경우에만 표시됩니다.페이지 번호
{% set page_offset = 2 %}
를 사용하여 현재 페이지 주변에 몇 개의 페이지 번호를 표시할 것인지 설정합니다.start_page
와 end_page
변수를 계산하여 현재 페이지 주변의 페이지 번호 범위를 결정합니다.range(start_page, end_page + 1)
을 사용하여 해당 범위의 페이지 번호를 반복적으로 표시합니다.#2980b9
)으로 강조되며, 다른 페이지 번호는 클릭 가능한 링크로 표시됩니다."Next" 버튼
{% if data.current_page < data.total_pages %}
구문을 통해 현재 페이지가 총 페이지 수보다 작은 경우에만 표시됩니다."Last" 버튼
href
속성을 통해 해당 버튼을 클릭하면 /?page=
뒤에 총 페이지 수가 추가되어 마지막 페이지로 이동하게 됩니다.<!-- 페이지네이션 컨트롤 -->
<div class="pagination">
<!-- 첫 페이지 버튼 -->
<a href="/?page=1{{ '&channel_id=' + data.channel_id if data.channel_id else '' }}">First</a>
<!-- 이전 페이지 버튼 -->
{% if data.current_page > 1 %}
<a href="/?page={{ data.current_page - 1 }}{{ '&channel_id=' + data.channel_id if data.channel_id else '' }}">Prev</a>
{% endif %}
{% set page_offset = 2 %}
{% set start_page = [data.current_page - page_offset, 1]|max %}
{% set end_page = [start_page + 4, data.total_pages]|min %}
<!-- 현재 페이지 주변의 페이지 번호들 (최대 5개) -->
{% for page in range(start_page, end_page + 1) %}
{% if page == data.current_page %}
<a href="/?page={{ page }}&channel_id={{ data.channel_id }}" style="background-color: #2980b9; color: white;">{{ page }}</a>
{% else %}
<a href="/?page={{ page }}&channel_id={{ data.channel_id }}">{{ page }}</a>
{% endif %}
{% endfor %}
<!-- 다음 페이지 버튼 -->
{% if data.current_page < data.total_pages %}
<a href="/?page={{ data.current_page + 1 }}{{ '&channel_id=' + data.channel_id if data.channel_id else '' }}">Next</a>
{% endif %}
<!-- 마지막 페이지 버튼 -->
<a href="/?page={{ data.total_pages }}{{ '&channel_id=' + data.channel_id if data.channel_id else '' }}">Last</a>
</div>
이미 MongoDB에 저장된 Subtitle 에 대하여 다운로드 버튼 비활성화해야 합니다.
즉 이미 MongoDB에 각 영상의 transcription이 존재하는 경우 "Get Videos" 버튼을 비활성화하고 "이미 DB에 있어요"라고 표시하는 기능을 구현하려면,
Flask 템플릿에서 조건부 렌더링을 사용하면 됩니다.
이 방법은 추가 라우트를 만들지 않고도 현재 구조 내에서 해결할 수 있습니다.
각 비디오의 ID를 사용하여 MongoDB에서 transcription의 존재 여부를 확인합니다.
이 정보는 결과 딕셔너리에 "transcription_exists" 키로 추가되며,
이후 HTML 템플릿에서 이 정보를 사용하여 사용자 인터페이스에 해당 정보를 표시할 수 있습니다.
# 특정 페이지의 비디오만 조회하는 쿼리
def get_videos_by_page(channel_id, page, per_page):
with sqlite3.connect('videos.db') as connect:
cursor = connect.cursor()
offset = (page - 1) * per_page
query = """
SELECT video.vid, video.video_id, video.title, video.link, video.published_at
FROM video
INNER JOIN channel ON video.cid = channel.cid
WHERE channel.channel_id = ?
ORDER BY video.published_at DESC
LIMIT ? OFFSET ?
"""
cursor.execute(query, (channel_id, per_page, offset))
rows = cursor.fetchall()
# 비디오 정보에 transcription 존재 여부 추가
video_info = []
for row in rows:
video_id = row[1]
transcription_exists = check_transcription_none(channel_id, video_id) is not True
video_info.append({
"vid": row[0],
"video_id": video_id,
"title": row[2],
"link": row[3],
"published_at": row[4],
"transcription_exists": transcription_exists
})
return video_info
이렇게 하면 MongoDB에 transcription이 존재하는 영상에 대해서는 "이미 DB에 있어요"라는 메시지와 함께 비활성화된 버튼이 표시되고,
그렇지 않은 경우에는 정상적으로 "Download Subtitle" 링크가 활성화됩니다.
추가적인 라우트를 만들지 않고도 이 기능을 구현할 수 있습니다.
<div class="card">
<a href="{{ video.link }}" class="video-link">{{ video.title }}</a>
<p class="published-date">{{ video.published_at }}</p><!--업로드 날짜 표시-->
<!-- transcription 존재 여부에 따라 버튼 상태와 메시지를 변경합니다. -->
{% if video.transcription_exists %}
<button disabled>이미 DB에 있어요</button>
{% else %}
<a href="/download/{{ data.channel_id }}/{{ video.video_id }}" class="download-link">Download Subtitle</a>
{% endif %}
</div>
로딩 애니메이션 가운데 정렬
/* 로딩 애니메이션 위치 조정 */
#loadingAnimation {
text-align: center; /* 가운데 정렬 */
padding-top: 20px; /* description-container와의 간격 */
}
/* 로딩 애니메이션을 위한 원형 스피너를 생성 */
.loader {
margin: auto; /* 가운데 정렬 */
border: 5px solid #f3f3f3; /* Light grey */
border-top: 5px solid #3498db; /* Blue */
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 2s linear infinite;
}
마우스 금지 표시 해제
/* 마우스 금지 표시 해제 */
input[type="submit"]:disabled {
background-color: #cccccc; /* 회색 */
/*cursor: not-allowed; /* 커서 변경 */*/
cursor: pointer; /* 커서 변경 */
}