Improved modapp

This commit is contained in:
2024-10-11 19:36:58 +01:00
parent 79ed713a18
commit b8edf8a7f1
12 changed files with 378 additions and 27 deletions

135
app.py
View File

@@ -1,11 +1,15 @@
from flask import Flask, render_template, request, redirect, url_for, flash, abort, make_response, session from flask import Flask, render_template, request, redirect, url_for, flash, abort, make_response, session, jsonify
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_cors import CORS
import datetime import datetime
import json import json
import random import random
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError from argon2.exceptions import VerifyMismatchError
app = Flask(__name__) app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///quotes.db' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///quotes.db'
app.config['SECRET_KEY'] = 'your_secret_key' # Use environment variable in production app.config['SECRET_KEY'] = 'your_secret_key' # Use environment variable in production
@@ -21,13 +25,18 @@ ADMIN_CREDENTIALS = {
'password': '$argon2i$v=19$m=65536,t=4,p=1$cWZDc1pQaUJLTUJoaVI4cw$kn8XKz6AEZi8ebXfyyZuzommSypliVFrsGqzOyUEIHA' # Example hash 'password': '$argon2i$v=19$m=65536,t=4,p=1$cWZDc1pQaUJLTUJoaVI4cw$kn8XKz6AEZi8ebXfyyZuzommSypliVFrsGqzOyUEIHA' # Example hash
} }
# Define the Quote model # Define the Quote modelclass Quote(db.Model):
class Quote(db.Model): class Quote(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
text = db.Column(db.Text, nullable=False) text = db.Column(db.Text, nullable=False)
votes = db.Column(db.Integer, default=0) votes = db.Column(db.Integer, default=0)
date = db.Column(db.DateTime, default=datetime.datetime.utcnow) date = db.Column(db.DateTime, default=datetime.datetime.utcnow)
status = db.Column(db.Integer, default=0) # 0 = pending, 1 = approved, 2 = rejected status = db.Column(db.Integer, default=0) # 0 = pending, 1 = approved, 2 = rejected
ip_address = db.Column(db.String(45)) # Store IPv4 and IPv6 addresses
user_agent = db.Column(db.String(255)) # Store user-agent strings
submitted_at = db.Column(db.DateTime, default=datetime.datetime.utcnow)
# Home route to display quotes # Home route to display quotes
# Home route to display quotes # Home route to display quotes
@@ -51,7 +60,11 @@ def submit():
flash("Quote cannot be empty.", 'error') flash("Quote cannot be empty.", 'error')
return redirect(url_for('submit')) return redirect(url_for('submit'))
new_quote = Quote(text=quote_text) ip_address = request.remote_addr # Get the user's IP address
user_agent = request.headers.get('User-Agent') # Get the user's browser info
new_quote = Quote(text=quote_text, ip_address=ip_address, user_agent=user_agent)
try: try:
db.session.add(new_quote) db.session.add(new_quote)
db.session.commit() db.session.commit()
@@ -68,7 +81,6 @@ def submit():
return render_template('submit.html', approved_count=approved_count, pending_count=pending_count) return render_template('submit.html', approved_count=approved_count, pending_count=pending_count)
# Route to handle voting (upvote/downvote) # Route to handle voting (upvote/downvote)
@app.route('/vote/<int:id>/<action>') @app.route('/vote/<int:id>/<action>')
def vote(id, action): def vote(id, action):
@@ -151,6 +163,10 @@ def quote():
quote = Quote.query.get_or_404(quote_id) quote = Quote.query.get_or_404(quote_id)
return render_template('quote.html', quote=quote) return render_template('quote.html', quote=quote)
@app.route('/faq')
def faq():
return render_template('faq.html')
# Admin login route # Admin login route
@app.route('/login', methods=['GET', 'POST']) @app.route('/login', methods=['GET', 'POST'])
def login(): def login():
@@ -179,10 +195,10 @@ def modapp():
flash('You need to log in first.', 'danger') flash('You need to log in first.', 'danger')
return redirect(url_for('login')) return redirect(url_for('login'))
# Fetch all quotes (pending, approved, and rejected)
all_quotes = Quote.query.order_by(Quote.date.desc()).all() all_quotes = Quote.query.order_by(Quote.date.desc()).all()
total_quotes = Quote.query.filter_by(status=1).count() # Count only approved quotes
return render_template('modapp.html', all_quotes=all_quotes) return render_template('modapp.html', all_quotes=all_quotes, total_quotes=total_quotes)
@app.route('/modapp/bulk_action', methods=['POST']) @app.route('/modapp/bulk_action', methods=['POST'])
def bulk_action(): def bulk_action():
@@ -298,6 +314,113 @@ def logout():
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
# Initialize rate limiter and CORS for cross-origin API access
limiter = Limiter(app, key_func=get_remote_address)
CORS(app)
# API to get all approved quotes
@app.route('/api/quotes', methods=['GET'])
def get_all_quotes():
quotes = Quote.query.filter_by(status=1).all() # Only approved quotes
quote_list = [{
'id': quote.id,
'text': quote.text,
'votes': quote.votes,
'date': quote.date.strftime('%Y-%m-%d')
} for quote in quotes]
return jsonify(quote_list), 200
# API to get a specific quote by ID
@app.route('/api/quotes/<int:id>', methods=['GET'])
def get_quote(id):
quote = Quote.query.filter_by(id=id, status=1).first_or_404() # Only approved quotes
quote_data = {
'id': quote.id,
'text': quote.text,
'votes': quote.votes,
'date': quote.date.strftime('%Y-%m-%d')
}
return jsonify(quote_data), 200
# API to get a random approved quote
@app.route('/api/random', methods=['GET'])
def get_random_quote():
count = Quote.query.filter_by(status=1).count()
if count == 0:
return jsonify({"error": "No approved quotes available"}), 404
random_offset = random.randint(0, count - 1)
random_quote = Quote.query.filter_by(status=1).offset(random_offset).first()
quote_data = {
'id': random_quote.id,
'text': random_quote.text,
'votes': random_quote.votes,
'date': random_quote.date.strftime('%Y-%m-%d')
}
return jsonify(quote_data), 200
# API to get the top quotes by vote count
@app.route('/api/top', methods=['GET'])
def get_top_quotes():
top_quotes = Quote.query.filter_by(status=1).order_by(Quote.votes.desc()).limit(10).all() # Limit to top 10
quote_list = [{
'id': quote.id,
'text': quote.text,
'votes': quote.votes,
'date': quote.date.strftime('%Y-%m-%d')
} for quote in top_quotes]
return jsonify(quote_list), 200
# API to search for quotes
@app.route('/api/search', methods=['GET'])
def search_quotes():
query = request.args.get('q', '').strip()
if not query:
return jsonify({"error": "No search term provided"}), 400
quotes = Quote.query.filter(Quote.text.ilike(f'%{query}%'), Quote.status == 1).all() # Search in approved quotes
if not quotes:
return jsonify({"error": "No quotes found for search term"}), 404
quote_list = [{
'id': quote.id,
'text': quote.text,
'votes': quote.votes,
'date': quote.date.strftime('%Y-%m-%d')
} for quote in quotes]
return jsonify(quote_list), 200
# API to submit a new quote
@app.route('/api/submit', methods=['POST'])
@limiter.limit("5 per minute") # Rate limiting to prevent abuse
def submit_quote():
data = request.get_json()
# Validate the input
if not data or not data.get('text'):
return jsonify({"error": "Quote text is required"}), 400
quote_text = data.get('text').strip()
# Basic validation to prevent spam
if len(quote_text) < 5 or len(quote_text) > 1000:
return jsonify({"error": "Quote must be between 5 and 1000 characters"}), 400
new_quote = Quote(text=quote_text)
try:
db.session.add(new_quote)
db.session.commit()
return jsonify({"success": "Quote submitted successfully!", "id": new_quote.id}), 201
except Exception as e:
db.session.rollback()
return jsonify({"error": f"Error submitting quote: {str(e)}"}), 500
# Run the Flask app # Run the Flask app
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='127.0.0.1', port=5050, debug=True) app.run(host='127.0.0.1', port=5050, debug=True)

View File

@@ -1,2 +1,5 @@
flask_sqlalchemy Flask==2.3.2
argon2-cffi Flask-SQLAlchemy==3.0.5
Flask-Limiter==2.4
Flask-CORS==3.0.10
argon2-cffi==21.3.0

View File

@@ -5,12 +5,45 @@ body {
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
transition: background-color 0.3s, color 0.3s;
}
/* Dark Mode */
@media (prefers-color-scheme: dark) {
body {
background-color: #121212;
color: #ffffff;
}
a {
color: #ffa500;
}
table, td, input.text, textarea, select {
background-color: #1e1e1e;
color: #ffffff;
border-color: #ffa500;
}
input.button {
background-color: #ffa500;
color: #000000;
}
input.button:hover {
background-color: #ff8c00;
}
.footertext, .toplinks, h2 {
color: #ffa500;
}
} }
/* Links */ /* Links */
a { a {
color: #c08000; color: #c08000;
text-decoration: none; text-decoration: none;
transition: color 0.3s;
} }
a:visited, a:link, a:hover { a:visited, a:link, a:hover {
@@ -78,6 +111,7 @@ input.text, textarea, select {
padding: 5px; padding: 5px;
border: 1px solid #c08000; border: 1px solid #c08000;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.3s, color 0.3s;
} }
input.button { input.button {
@@ -86,6 +120,7 @@ input.button {
padding: 5px 10px; padding: 5px 10px;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: background-color 0.3s;
} }
input.button:hover { input.button:hover {
@@ -141,6 +176,7 @@ footer {
border: 1px solid #c08000; border: 1px solid #c08000;
margin: 0 5px; margin: 0 5px;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.3s, color 0.3s;
} }
#pagination a:hover { #pagination a:hover {
@@ -167,3 +203,29 @@ footer {
.quote-id:hover { .quote-id:hover {
text-decoration: underline; text-decoration: underline;
} }
/* Dark Mode Specific */
@media (prefers-color-scheme: dark) {
td.quote-box {
border-color: #ffa500;
}
footer {
background-color: #ffa500;
color: #121212;
}
#pagination a {
border-color: #ffa500;
color: #ffa500;
}
#pagination a:hover {
background-color: #333333;
color: #ffffff;
}
.quote-id {
color: #ffa500;
}
}

View File

@@ -32,7 +32,9 @@
<a href="/random">Random</a> / <a href="/random">Random</a> /
<a href="/submit">Submit</a> / <a href="/submit">Submit</a> /
<a href="/browse">Browse</a> / <a href="/browse">Browse</a> /
<a href="/modapp">ModApp</a> <a href="/modapp">ModApp</a> /
<a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
</table> </table>
@@ -79,6 +81,9 @@
<a href="/random">Random</a> / <a href="/random">Random</a> /
<a href="/submit">Submit</a> / <a href="/submit">Submit</a> /
<a href="/browse">Browse</a> / <a href="/browse">Browse</a> /
<a href="/modapp">ModApp</a> /
<a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
<tr> <tr>

123
templates/faq.html Normal file
View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ircquotes: FAQ</title>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body bgcolor="#ffffff" text="#000000" link="#c08000" vlink="#c08000" alink="#c08000">
<!-- Navigation -->
<center>
<table cellpadding="2" cellspacing="0" width="80%" border="0">
<tr>
<td bgcolor="#c08000" align="left">
<font size="+1"><b><i>ircquotes</i></b></font>
</td>
<td bgcolor="#c08000" align="right">
<font face="arial" size="+1"><b>FAQ</b></font>
</td>
</tr>
</table>
<table cellpadding="2" cellspacing="0" width="80%" border="0">
<tr>
<td class="footertext" align="left" bgcolor="#f0f0f0"></td>
<td align="right" bgcolor="#f0f0f0" class="toplinks" colspan="2">
<a href="/">Home</a> /
<a href="/random">Random</a> /
<a href="/submit">Submit</a> /
<a href="/browse">Browse</a> /
<a href="/modapp">ModApp</a> /
<a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td>
</tr>
</table>
</center>
<!-- FAQ Content -->
<center>
<table cellpadding="2" cellspacing="0" width="80%">
<tr>
<td class="bodytext">
<h2>Frequently Asked Questions (FAQ)</h2>
<h3>What is ircquotes?</h3>
<p>ircquotes is a community-driven website where users can submit and browse memorable quotes from IRC (Internet Relay Chat). You can browse quotes, submit your own, and vote on others.</p>
<h3>How does the API work?</h3>
<p>The ircquotes API allows users to retrieve quotes programmatically. It is designed for developers who want to integrate IRC quotes into their own applications.</p>
<h4>Available API Endpoints</h4>
<ul>
<li><strong>Get All Approved Quotes</strong>: <code>GET /api/quotes</code></li>
<li><strong>Get a Specific Quote by ID</strong>: <code>GET /api/quotes/&lt;id&gt;</code></li>
<li><strong>Get a Random Quote</strong>: <code>GET /api/random</code></li>
<li><strong>Get Top Quotes</strong>: <code>GET /api/top</code></li>
<li><strong>Search Quotes</strong>: <code>GET /api/search?q=&lt;search_term&gt;</code></li>
</ul>
<h4>Submitting Quotes via the API</h4>
<p>The API also allows you to submit quotes, but this feature is rate-limited to prevent abuse. Each user is allowed 5 submissions per minute.</p>
<ul>
<li><strong>Submit a Quote</strong>: <code>POST /api/submit</code></li>
<li><strong>Request Body</strong>: The request body should be in JSON format and contain the quote text like this:</li>
<pre><code>{
"text": "This is a memorable quote!"
}</code></pre>
<li><strong>Validation Rules</strong>: Quotes must be between 5 and 1000 characters.</li>
</ul>
<h3>Rules for Submitting Quotes</h3>
<p>To ensure that ircquotes remains a fun and enjoyable platform for everyone, we ask that you follow a few simple rules when submitting quotes:</p>
<ul>
<li><strong>No Offensive Content</strong>: Do not submit quotes that contain offensive language, hate speech, or other harmful content.</li>
<li><strong>No Spam</strong>: Please avoid submitting irrelevant or repetitive content. The site is for memorable IRC quotes, not spam.</li>
<li><strong>Stay On-Topic</strong>: Ensure that your submissions are actual quotes from IRC, not made-up content.</li>
<li><strong>Rate Limiting</strong>: The submission rate is limited to 5 quotes per minute to prevent spam.</li>
<li><strong>Moderation</strong>: All quotes are subject to approval by site moderators. Rejected quotes will not be publicly visible.</li>
</ul>
<h3>How are quotes moderated?</h3>
<p>Quotes go through a moderation process where they are either approved, rejected, or deleted. Approved quotes become visible to all users, while rejected ones remain in the system but aren't shown publicly.</p>
<h3>How can I get the top 100 quotes?</h3>
<p>You can view the top 100 quotes by visiting the <a href="/top">Top 100 page</a>. The quotes are ranked by votes, with the highest-voted quotes appearing at the top.</p>
<h3>How do I search for quotes?</h3>
<p>Use the search bar on the site or the API endpoint <code>/api/search?q=&lt;search_term&gt;</code> to find specific quotes.</p>
<h3>Can I delete my submitted quotes?</h3>
<p>No, quotes are submitted anonymously, so there's no direct way to delete quotes after submission. However, site moderators can remove inappropriate content.</p>
<h3>Who can I contact for help?</h3>
<p>If you encounter any issues or have questions, feel free to reach out to the admin via the contact page or submit a help request through the site's interface.</p>
</td>
</tr>
</table>
</center>
<!-- Footer -->
<center>
<table border="0" cellpadding="2" cellspacing="0" width="80%" bgcolor="#c08000">
<tr>
<td bgcolor="#f0f0f0" class="toplinks" colspan="2">
<a href="/">Home</a> /
<a href="/random">Random</a> /
<a href="/submit">Submit</a> /
<a href="/browse">Browse</a> /
<a href="/modapp">ModApp</a> /
<a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td>
</tr>
<tr>
<td class="footertext" align="left">&nbsp;</td>
<td class="footertext" align="right">{{ total_quotes }} quotes approved</td>
</tr>
</table>
</center>
</body>
</html>

View File

@@ -33,8 +33,8 @@
<a href="/submit">Submit</a> / <a href="/submit">Submit</a> /
<a href="/browse">Browse</a> / <a href="/browse">Browse</a> /
<a href="/modapp">ModApp</a> / <a href="/modapp">ModApp</a> /
<!-- This link will now load search.html -->
<a href="/search">Search</a> <a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
</table> </table>

View File

@@ -32,6 +32,7 @@
<a href="/browse">Browse</a> / <a href="/browse">Browse</a> /
<a href="/modapp">ModApp</a> / <a href="/modapp">ModApp</a> /
<a href="/search">Search</a> <a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
</table> </table>
@@ -68,6 +69,9 @@
<a href="/random">Random</a> / <a href="/random">Random</a> /
<a href="/submit">Submit</a> / <a href="/submit">Submit</a> /
<a href="/browse">Browse</a> / <a href="/browse">Browse</a> /
<a href="/modapp">ModApp</a> /
<a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@@ -44,18 +44,22 @@
<!-- Check if there are any quotes --> <!-- Check if there are any quotes -->
{% if all_quotes %} {% if all_quotes %}
<form method="POST" action="/modapp/bulk_action"> <form method="POST" action="/modapp/bulk_action">
<table> <table border="1" cellpadding="5" cellspacing="0" width="100%">
<tr> <tr>
<th>Select</th> <th>Select</th>
<th>Quote ID</th> <th>Quote ID</th>
<th>Quote</th> <th>Quote</th>
<th>Status</th> <th>Status</th>
<th>Submitted At</th>
<th>IP Address</th>
<th>User Agent</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
<!-- Loop through each quote --> <!-- Loop through each quote -->
{% for quote in all_quotes %} {% for quote in all_quotes %}
<tr style="background-color: '{% if quote.status == 1 %}#d4edda{% elif quote.status == 2 %}#f8d7da{% else %}#fff{% endif %}'"> <tr style="background-color:
<td><input type="checkbox" name="quote_ids" value="{{ quote.id }}"></td> {% if quote.status == 1 %} #d4edda {% elif quote.status == 2 %} #f8d7da {% else %} #fff {% endif %}">
<td><input type="checkbox" name="quote_ids[]" value="{{ quote.id }}"></td>
<td>#{{ quote.id }}</td> <td>#{{ quote.id }}</td>
<td>{{ quote.text }}</td> <td>{{ quote.text }}</td>
<td> <td>
@@ -67,6 +71,15 @@
Rejected Rejected
{% endif %} {% endif %}
</td> </td>
<td>
{% if quote.submitted_at %}
{{ quote.submitted_at.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
N/A
{% endif %}
</td>
<td>{{ quote.ip_address }}</td>
<td>{{ quote.user_agent }}</td>
<td> <td>
<a href="/approve/{{ quote.id }}">Approve</a> | <a href="/approve/{{ quote.id }}">Approve</a> |
<a href="/reject/{{ quote.id }}">Reject</a> | <a href="/reject/{{ quote.id }}">Reject</a> |
@@ -109,5 +122,11 @@
</tr> </tr>
</table> </table>
</center> </center>
<!-- Dark Mode Toggle -->
<center>
<button id="theme-toggle">Toggle Dark Mode</button>
</center>
</body> </body>
</html> </html>

View File

@@ -32,7 +32,9 @@
<a href="/random">Random</a> / <a href="/random">Random</a> /
<a href="/submit">Submit</a> / <a href="/submit">Submit</a> /
<a href="/browse">Browse</a> / <a href="/browse">Browse</a> /
<a href="/top">Top 100</a> <a href="/modapp">ModApp</a> /
<a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
</table> </table>

View File

@@ -26,10 +26,12 @@
<td class="footertext" align="left" bgcolor="#f0f0f0"></td> <td class="footertext" align="left" bgcolor="#f0f0f0"></td>
<td align="right" bgcolor="#f0f0f0" class="toplinks" colspan="2"> <td align="right" bgcolor="#f0f0f0" class="toplinks" colspan="2">
<a href="/">Home</a> / <a href="/">Home</a> /
<a href="/?latest">Latest</a> / <a href="/random">Random</a> /
<a href="/?browse">Browse</a> / <a href="/submit">Submit</a> /
<a href="/random" rel="nofollow">Random</a> / <a href="/browse">Browse</a> /
<a href="/?top">Top 100</a> <a href="/modapp">ModApp</a> /
<a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
</table> </table>
@@ -57,10 +59,12 @@
<tr> <tr>
<td bgcolor="#f0f0f0" class="toplinks" colspan="2"> <td bgcolor="#f0f0f0" class="toplinks" colspan="2">
<a href="/">Home</a> / <a href="/">Home</a> /
<a href="/?latest">Latest</a> / <a href="/random">Random</a> /
<a href="/?browse">Browse</a> / <a href="/submit">Submit</a> /
<a href="/random" rel="nofollow">Random</a> / <a href="/browse">Browse</a> /
<a href="/?top">Top 100</a> <a href="/modapp">ModApp</a> /
<a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@@ -32,6 +32,7 @@
<a href="/browse">Browse</a> / <a href="/browse">Browse</a> /
<a href="/modapp">ModApp</a> / <a href="/modapp">ModApp</a> /
<a href="/search">Search</a> <a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
</table> </table>
@@ -83,6 +84,9 @@
<a href="/random">Random</a> / <a href="/random">Random</a> /
<a href="/submit">Submit</a> / <a href="/submit">Submit</a> /
<a href="/browse">Browse</a> / <a href="/browse">Browse</a> /
<a href="/modapp">ModApp</a> /
<a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@@ -29,9 +29,11 @@
<td align="right" bgcolor="#f0f0f0" class="toplinks" colspan="2"> <td align="right" bgcolor="#f0f0f0" class="toplinks" colspan="2">
<a href="/">Home</a> / <a href="/">Home</a> /
<a href="/random">Random</a> / <a href="/random">Random</a> /
<a href="/submit"><b>Add Quote</b></a> / <a href="/submit">Submit</a> /
<a href="/browse">Browse</a> /
<a href="/modapp">ModApp</a> / <a href="/modapp">ModApp</a> /
<a href="/search">Search</a> <a href="/search">Search</a>
<a href="/faq">FAQ</a>
</td> </td>
</tr> </tr>
</table> </table>